Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 26 additions & 15 deletions apps/remix-ide/src/app/plugins/remixAI/ApiKeySettingsHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,23 +66,26 @@ export class ApiKeySettingsHelper {
const hasPermission = await this.canUseOwnApiKeys()

// Read settings via plugin calls (parallel for performance). We read the
// Bedrock API key regardless of `hasPermission`, because Bedrock has no
// Remix proxy: if the user entered a key it must be used, there's no
// proxy alternative to gate it against. The permission flag only governs
// the proxy-backed providers below.
// Bedrock API key regardless of `hasPermission`: a present key means the
// user wants direct access, which must be honoured. When absent, Bedrock
// falls back to the Remix proxy (handled in the ModelFactory). The
// permission flag only governs own-key access on the proxy-backed
// providers below.
const [
useOwnKeysValue,
anthropicApiKey,
mistralApiKey,
openaiApiKey,
moonshotApiKey,
openrouterApiKey,
bedrockBearerToken
] = await Promise.all([
this.getSetting('deepagent-api-keys-config'),
this.getSetting('deepagent-anthropic-api-key'),
this.getSetting('deepagent-mistral-api-key'),
this.getSetting('deepagent-openai-api-key'),
this.getSetting('deepagent-moonshot-api-key'),
this.getSetting('deepagent-openrouter-api-key'),
this.getSetting('deepagent-bedrock-bearer-token')
])

Expand All @@ -102,22 +105,27 @@ export class ApiKeySettingsHelper {
remixAILogger.log('[ApiKeySettingsHelper] Reading API keys from settings:', {
hasPermission,
useOwnKeys,
hasAnyProxyKey,
hasBedrockKey
hasAnthropicKey: !!anthropicApiKey,
hasMistralKey: !!mistralApiKey,
hasOpenaiKey: !!openaiApiKey,
hasMoonshotKey: !!moonshotApiKey,
hasOpenrouterKey: !!openrouterApiKey,
hasBedrockKey: !!hasBedrockKey
})

// Nothing to contribute → callers fall back to the proxy.
// - No Bedrock key AND no proxy-provider own keys in play.
// Auto-enable if any API key is set
const hasAnyKey = anthropicApiKey || mistralApiKey || openaiApiKey || moonshotApiKey || openrouterApiKey
if (!hasBedrockKey && !hasAnyProxyKey && !(useOwnKeys && hasPermission)) {
return undefined
}

return {
useOwnKeys: (useOwnKeys && hasPermission) || hasAnyProxyKey || hasBedrockKey,
anthropicApiKey: anthropic,
mistralApiKey: mistral,
openaiApiKey: openai,
moonshotApiKey: moonshot,
useOwnKeys: useOwnKeys || !!hasAnyKey,
anthropicApiKey: String(anthropicApiKey || ''),
mistralApiKey: String(mistralApiKey || ''),
openaiApiKey: String(openaiApiKey || ''),
moonshotApiKey: String(moonshotApiKey || ''),
openrouterApiKey: String(openrouterApiKey || ''),
bedrockBearerToken: String(bedrockBearerToken || '')
}
} catch (error) {
Expand All @@ -131,8 +139,8 @@ export class ApiKeySettingsHelper {
*/
async isUsingOwnApiKeyForProvider(provider: string): Promise<boolean> {
try {
// Bedrock has no proxy — it's always "own key" when a Bedrock API key is
// present, independent of the proxy-vs-own-key toggle.
// A present Bedrock API key means direct ("own key") access, independent
// of the proxy-vs-own-key toggle; without one we route through the proxy.
if (provider === 'bedrock') {
return !!(await this.getSetting('deepagent-bedrock-bearer-token'))
}
Expand All @@ -156,6 +164,9 @@ export class ApiKeySettingsHelper {
case 'moonshot':
apiKey = await this.getSetting('deepagent-moonshot-api-key')
break
case 'openrouter':
apiKey = await this.getSetting('deepagent-openrouter-api-key')
break
default:
return false
}
Expand Down
4 changes: 2 additions & 2 deletions apps/remix-ide/src/app/plugins/remixAI/DeepAgentManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ export class DeepAgentManager {
},
fallbackInferencer,
plugin.mcpInferencer,
{ provider: plugin.selectedModel.provider as 'anthropic' | 'mistralai' | 'openai' | 'moonshot' | 'ollama' | 'bedrock', modelId: resolvedModelId }
{ provider: plugin.selectedModel.provider as 'anthropic' | 'mistralai' | 'openai' | 'moonshot' | 'openrouter' | 'ollama' | 'bedrock', modelId: resolvedModelId }
)

await plugin.deepAgentInferencer.initialize()
Expand Down Expand Up @@ -330,7 +330,7 @@ export class DeepAgentManager {
},
fallbackInferencer,
plugin.mcpInferencer,
{ provider: plugin.selectedModel.provider as 'anthropic' | 'mistralai' | 'openai' | 'moonshot' | 'ollama' | 'bedrock', modelId: resolvedModelId }
{ provider: plugin.selectedModel.provider as 'anthropic' | 'mistralai' | 'openai' | 'moonshot' | 'openrouter' | 'ollama' | 'bedrock', modelId: resolvedModelId }
)
await plugin.deepAgentInferencer.initialize()
plugin.deepAgentEnabled = true
Expand Down
2 changes: 1 addition & 1 deletion apps/remix-ide/src/app/plugins/remixAI/MCPServerManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { PermissionChecker } from './PermissionChecker'
export interface MCPServerManagerDeps {
plugin: IRemixAIPlugin
permissionChecker: PermissionChecker
setModel: (modelId: string) => Promise<void>
setModel: (modelId: string, provider?: string) => Promise<void>
reinitializeDeepAgent: () => Promise<void>
}

Expand Down
17 changes: 7 additions & 10 deletions apps/remix-ide/src/app/plugins/remixAI/ModelManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import { remixAILogger,
getBestAvailableModel,
listModels,
modelSupportsTools,
getModelById
getModelById,
findModel,
ANONYMOUS_FALLBACK_MODELS
} from '@remix/remix-ai-core'
import type { AIModel } from '@remix/remix-ai-core'
import type { IRemixAIPlugin } from './types'
Expand All @@ -26,23 +28,18 @@ export class ModelManager {
this.deps = deps
}

async setModel(modelId: string, allowedModels: string[] = []): Promise<void> {
async setModel(modelId: string, allowedModels: string[] = [], provider?: string): Promise<void> {
const plugin = this.deps.plugin
// The static `getModelById` only knows the anonymous fallback list
// (placeholder + ollama). Real model metadata lives in the
// assistantState plugin, fed by /permissions.ai_models. Look it up
// there first; only fall back to the static helper for the bootstrap
// / ollama cases.
let model: AIModel | undefined
try {
const dynamic: AIModel[] = await plugin.call('assistantState', 'getAvailableModels')
if (Array.isArray(dynamic)) {
model = dynamic.find(m => m.id === modelId)
model = findModel(dynamic, modelId, provider)
}
} catch (e) {
remixAILogger.warn('[ModelManager] assistantState.getAvailableModels failed', e)
}
if (!model) model = getModelById(modelId)
if (!model) model = findModel(ANONYMOUS_FALLBACK_MODELS, modelId, provider) ?? getModelById(modelId)
if (!model) {
// No silent fallback. The picker is fed by /permissions — if a
// caller asks for a model id that isn't in any catalogue we have a
Expand Down Expand Up @@ -180,7 +177,7 @@ export class ModelManager {
throw new Error(`[ModelManager.setAssistantProvider] No available model for provider "${provider}" in /permissions ai_models. Backend must advertise at least one row for this provider.`)
}
const chosen = candidates.find(m => m.isDefault) ?? candidates[0]
await this.setModel(chosen.id)
await this.setModel(chosen.id, [], chosen.provider)
}

async getOllamaModels(): Promise<{ name: string; supported: boolean }[]> {
Expand Down
17 changes: 6 additions & 11 deletions apps/remix-ide/src/app/plugins/remixAIPlugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ export class RemixAIPlugin extends Plugin {
this.mcpManager.setDeps({
plugin: this as any,
permissionChecker: this.permissionChecker,
setModel: (modelId: string) => this.modelManager.setModel(modelId),
setModel: (modelId: string, provider?: string) => this.modelManager.setModel(modelId, [], provider),
reinitializeDeepAgent: () => this.deepAgentManager.reinitialize()
})

Expand Down Expand Up @@ -357,7 +357,7 @@ export class RemixAIPlugin extends Plugin {
// GenerationParams/CompletionParams pick up the provider+model
// and DeepAgent (if enabled) reinitialises.
try {
await this.setModel(def.id)
await this.setModel(def.id, def.provider)
} catch (e) {
remixAILogger.warn('[RemixAI Plugin] setModel failed during initial /permissions resolution', e)
}
Expand Down Expand Up @@ -513,7 +513,7 @@ export class RemixAIPlugin extends Plugin {
},
fallbackInferencer,
this.mcpInferencer, // Pass MCPInferencer to gather external MCP client tools
{ provider: this.selectedModel.provider as 'anthropic' | 'mistralai' | 'openai' | 'moonshot' | 'ollama' | 'bedrock', modelId: this.selectedModelId } // Pass selected model
{ provider: this.selectedModel.provider as 'anthropic' | 'mistralai' | 'openai' | 'moonshot' | 'openrouter' | 'ollama' | 'bedrock', modelId: this.selectedModelId } // Pass selected model
)
await this.deepAgentInferencer.initialize()
// Set up DeepAgent event listeners for streaming (once only)
Expand Down Expand Up @@ -562,7 +562,7 @@ export class RemixAIPlugin extends Plugin {
// resolved one. Without an id the picker is empty and downstream
// setModel would throw — we let the assistantState subscription do it.
if (this.selectedModelId) {
await this.setModel(this.selectedModelId)
await this.setModel(this.selectedModelId, this.selectedModel?.provider)
} else {
remixAILogger.log('[RemixAI Plugin] initialize: no selectedModelId yet, deferring setModel until /permissions loads')
}
Expand Down Expand Up @@ -744,15 +744,10 @@ export class RemixAIPlugin extends Plugin {
}
}
remixAILogger.log('[answer][route-flow]', routeFlow)
console.log('[answer][route-flow] route', route)
if (!remoteRouteCheck && route === 'remote') {
remixAILogger.warn('[answer][route-flow] remote route selected but remoteInferencer is missing')
}
if (route === 'deepagent') {
// If a previous cancelRequest is still rebuilding the inferencer,
// wait for it to finish so this dispatch lands on the new
// instance with a clean LangGraph pipe rather than racing the
// about-to-be-discarded one.
await this.deepAgentManager.awaitReady()
remixAILogger.log('[answer][route-flow] dispatch=deepagent.answer')
return await this.deepAgentInferencer.answer(newPrompt, params, this.workspaceAgent.ctxFiles || '')
Expand Down Expand Up @@ -1014,8 +1009,8 @@ export class RemixAIPlugin extends Plugin {
return this.modelManager.setAssistantProvider(provider)
}

async setModel(modelId: string, allowedModels: string[] = []) {
return this.modelManager.setModel(modelId, allowedModels)
async setModel(modelId: string, provider?: string, allowedModels: string[] = []) {
return this.modelManager.setModel(modelId, allowedModels, provider)
}

async setOllamaModel(ollamaModelName: string) {
Expand Down
1 change: 1 addition & 0 deletions apps/remix-ide/src/app/tabs/locales/en/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@
"settings.deepagent-mistral-api-key": "MistralAI API Key",
"settings.deepagent-openai-api-key": "OpenAI API Key",
"settings.deepagent-moonshot-api-key": "Moonshot/Kimi API Key",
"settings.deepagent-openrouter-api-key": "OpenRouter API Key",
"settings.deepagent-bedrock-bearer-token": "AWS Bedrock API Key",
"settings.testApiKey": "Test",
"settings.testing": "Testing...",
Expand Down
57 changes: 55 additions & 2 deletions libs/remix-ai-core/src/helpers/apiKeyValidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,16 @@ export function validateApiKeyFormat(provider: ModelProvider, apiKey: string): A
}
break

case 'openrouter':
if (!trimmedKey.startsWith('sk-or-')) {
return {
isValid: false,
provider,
error: 'OpenRouter API key should start with "sk-or-"'
}
}
break

case 'bedrock':
if (trimmedKey.length < 20) {
return {
Expand Down Expand Up @@ -127,6 +137,9 @@ export async function testApiKey(provider: ModelProvider, apiKey: string): Promi
case 'moonshot':
return await testMoonshotKey(trimmedKey)

case 'openrouter':
return await testOpenRouterKey(trimmedKey)

case 'bedrock':
return await testBedrockKey(trimmedKey)

Expand Down Expand Up @@ -324,6 +337,47 @@ async function testMoonshotKey(apiKey: string): Promise<ApiKeyValidationResult>
}
}

async function testOpenRouterKey(apiKey: string): Promise<ApiKeyValidationResult> {
try {
const response = await fetch('https://openrouter.ai/api/v1/key', {
method: 'GET',
headers: {
'Authorization': `Bearer ${apiKey}`
}
})

if (response.ok) {
return { isValid: true, provider: 'openrouter' }
}

if (response.status === 401) {
return {
isValid: false,
provider: 'openrouter',
error: 'Invalid API key - authentication failed'
}
}

if (response.status === 429) {
// Rate limited but key is valid
return { isValid: true, provider: 'openrouter' }
}

const errorData = await response.json().catch(() => ({}))
return {
isValid: false,
provider: 'openrouter',
error: errorData?.error?.message || `API returned status ${response.status}`
}
} catch (error: any) {
return {
isValid: false,
provider: 'openrouter',
error: error?.message || 'Network error testing API key'
}
}
}

async function testBedrockKey(apiKey: string): Promise<ApiKeyValidationResult> {
const region = 'us-east-1'
const modelId = 'amazon.nova-micro-v1:0'
Expand Down Expand Up @@ -358,8 +412,6 @@ async function testBedrockKey(apiKey: string): Promise<ApiKeyValidationResult> {
}
}

// Throttled, or a request-shape validation error — the token still
// authenticated (an invalid one is rejected with 401/403 first).
if (response.status === 429 || response.status === 400) {
return { isValid: true, provider: 'bedrock' }
}
Expand All @@ -382,6 +434,7 @@ async function testBedrockKey(apiKey: string): Promise<ApiKeyValidationResult> {
export function getProviderFromSettingKey(settingKey: string): ModelProvider | null {
if (settingKey.includes('bedrock')) return 'bedrock'
if (settingKey.includes('anthropic')) return 'anthropic'
if (settingKey.includes('openrouter')) return 'openrouter'
if (settingKey.includes('openai')) return 'openai'
if (settingKey.includes('mistral')) return 'mistralai'
if (settingKey.includes('moonshot')) return 'moonshot'
Expand Down
73 changes: 73 additions & 0 deletions libs/remix-ai-core/src/helpers/structuredOutput.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { z } from 'zod'
import { BaseChatModel } from '@langchain/core/language_models/chat_models'
import { BaseMessage, HumanMessage } from '@langchain/core/messages'
import { remixAILogger } from './logger'

export type StructuredMethod = 'functionCalling' | 'jsonSchema' | 'jsonMode'

export interface StructuredOutputOptions {
/** Tool/schema name surfaced to the provider. */
name?: string
/**
* Enforcement mechanism. 'functionCalling' (default) works across all
* providers Remix uses; 'jsonSchema' enables strict json-schema mode on
* OpenAI/Anthropic.
*/
method?: StructuredMethod
/** How many repair re-prompts to attempt on validation failure (default 1). */
maxRepairs?: number
}

/**
* Generate schema-constrained output from a LangChain chat model.
*
* Uses `model.withStructuredOutput(schema)` — the provider constrains the model
* (via tool-calling or json-schema) to the shape, and LangChain parses it. If
* parsing/validation still fails, we run a bounded repair loop: re-invoke with
* the validation error appended, asking for a value that matches the schema.
* This mirrors the single-retry philosophy already used for tool-input schema
* mismatches in `DeepAgentInferencer` (see its runAgent retry).
*
* Throws the last validation error if every attempt fails — callers decide how
* to surface that (fallback message, retry, etc.).
*/
export async function generateStructured<T>(
model: BaseChatModel,
schema: z.ZodType<T>,
messages: BaseMessage[],
opts: StructuredOutputOptions = {}
): Promise<T> {
const { name = 'extract', method, maxRepairs = 1 } = opts

// Cast: withStructuredOutput's overloads are picky about zod version generics;
// the runtime behaviour is identical.
const structured = (model as any).withStructuredOutput(schema, {
name,
...(method ? { method } : {})
})

const runningMessages: BaseMessage[] = [...messages]
let lastError: unknown

for (let attempt = 0; attempt <= maxRepairs; attempt++) {
try {
const raw = await structured.invoke(runningMessages)
// Double-validate: some providers return loosely-typed objects.
return schema.parse(raw)
} catch (error: any) {
lastError = error
if (attempt === maxRepairs) break
const detail = error?.message ? String(error.message) : String(error)
remixAILogger.warn(`[structuredOutput] "${name}" attempt ${attempt + 1} failed, repairing:`, detail)
runningMessages.push(
new HumanMessage(
`Your previous response did not match the required schema (${detail}). ` +
'Reply again with ONLY a value that strictly matches the schema — no prose, no markdown fences.'
)
)
}
}

remixAILogger.error(`[structuredOutput] "${name}" failed after ${maxRepairs + 1} attempt(s)`, lastError)
throw lastError
}
Loading
Loading