This repository was archived by the owner on Jul 7, 2026. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 614
feat: add custom providers #159
Closed
Closed
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -124,6 +124,12 @@ function decryptToken(encrypted: string): string { | |||||||||||||||||||||
| return safeStorage.decryptString(buffer) | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| function decryptIfNeeded(token: string): string { | ||||||||||||||||||||||
| if (!token) return token | ||||||||||||||||||||||
| if (!token.startsWith("enc:")) return token | ||||||||||||||||||||||
| return decryptToken(token.slice(4)) | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| /** | ||||||||||||||||||||||
| * Get Claude Code OAuth token from local SQLite | ||||||||||||||||||||||
| * Returns null if not connected | ||||||||||||||||||||||
|
|
@@ -553,7 +559,7 @@ export const claudeRouter = router({ | |||||||||||||||||||||
| customConfig: z | ||||||||||||||||||||||
| .object({ | ||||||||||||||||||||||
| model: z.string().min(1), | ||||||||||||||||||||||
| token: z.string().min(1), | ||||||||||||||||||||||
| token: z.string(), | ||||||||||||||||||||||
| baseUrl: z.string().min(1), | ||||||||||||||||||||||
| }) | ||||||||||||||||||||||
| .optional(), | ||||||||||||||||||||||
|
|
@@ -708,15 +714,21 @@ export const claudeRouter = router({ | |||||||||||||||||||||
| // Use offline config if available | ||||||||||||||||||||||
| const finalCustomConfig = offlineResult.config || input.customConfig | ||||||||||||||||||||||
| const isUsingOllama = offlineResult.isUsingOllama | ||||||||||||||||||||||
| const resolvedCustomConfig = finalCustomConfig | ||||||||||||||||||||||
| ? { | ||||||||||||||||||||||
| ...finalCustomConfig, | ||||||||||||||||||||||
| token: decryptIfNeeded(finalCustomConfig.token), | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| : undefined | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| // Track connection method for analytics | ||||||||||||||||||||||
| let connectionMethod = "claude-subscription" // default (Claude Code OAuth) | ||||||||||||||||||||||
| if (isUsingOllama) { | ||||||||||||||||||||||
| connectionMethod = "offline-ollama" | ||||||||||||||||||||||
| } else if (finalCustomConfig) { | ||||||||||||||||||||||
| } else if (resolvedCustomConfig) { | ||||||||||||||||||||||
| // Has custom config = either API key or custom model | ||||||||||||||||||||||
| const isDefaultAnthropicUrl = !finalCustomConfig.baseUrl || | ||||||||||||||||||||||
| finalCustomConfig.baseUrl.includes("anthropic.com") | ||||||||||||||||||||||
| const isDefaultAnthropicUrl = !resolvedCustomConfig.baseUrl || | ||||||||||||||||||||||
| resolvedCustomConfig.baseUrl.includes("anthropic.com") | ||||||||||||||||||||||
| connectionMethod = isDefaultAnthropicUrl ? "api-key" : "custom-model" | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| setConnectionMethod(connectionMethod) | ||||||||||||||||||||||
|
|
@@ -824,10 +836,12 @@ export const claudeRouter = router({ | |||||||||||||||||||||
|
|
||||||||||||||||||||||
| // Build full environment for Claude SDK (includes HOME, PATH, etc.) | ||||||||||||||||||||||
| const claudeEnv = buildClaudeEnv({ | ||||||||||||||||||||||
| ...(finalCustomConfig && { | ||||||||||||||||||||||
| ...(resolvedCustomConfig && { | ||||||||||||||||||||||
| customEnv: { | ||||||||||||||||||||||
| ANTHROPIC_AUTH_TOKEN: finalCustomConfig.token, | ||||||||||||||||||||||
| ANTHROPIC_BASE_URL: finalCustomConfig.baseUrl, | ||||||||||||||||||||||
| ...(resolvedCustomConfig.token && { | ||||||||||||||||||||||
| ANTHROPIC_AUTH_TOKEN: resolvedCustomConfig.token, | ||||||||||||||||||||||
| }), | ||||||||||||||||||||||
| ANTHROPIC_BASE_URL: resolvedCustomConfig.baseUrl, | ||||||||||||||||||||||
| }, | ||||||||||||||||||||||
| }), | ||||||||||||||||||||||
| enableTasks: input.enableTasks ?? true, | ||||||||||||||||||||||
|
|
@@ -983,7 +997,14 @@ export const claudeRouter = router({ | |||||||||||||||||||||
|
|
||||||||||||||||||||||
| // Build final env - only add OAuth token if we have one AND no existing API config | ||||||||||||||||||||||
| // Existing CLI config takes precedence over OAuth | ||||||||||||||||||||||
| const finalEnv = { | ||||||||||||||||||||||
| const finalEnv: { | ||||||||||||||||||||||
| [key: string]: string | undefined | ||||||||||||||||||||||
| CLAUDE_CODE_OAUTH_TOKEN?: string | ||||||||||||||||||||||
| CLAUDE_CONFIG_DIR: string | ||||||||||||||||||||||
| ANTHROPIC_BASE_URL?: string | ||||||||||||||||||||||
| ANTHROPIC_AUTH_TOKEN?: string | ||||||||||||||||||||||
| ANTHROPIC_API_KEY?: string | ||||||||||||||||||||||
| } = { | ||||||||||||||||||||||
| ...claudeEnv, | ||||||||||||||||||||||
| ...(claudeCodeToken && !hasExistingApiConfig && { | ||||||||||||||||||||||
| CLAUDE_CODE_OAUTH_TOKEN: claudeCodeToken, | ||||||||||||||||||||||
|
|
@@ -1012,38 +1033,40 @@ export const claudeRouter = router({ | |||||||||||||||||||||
| console.log(`[claude] ========== END SESSION DEBUG ==========`) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| console.log(`[SD] Query options - cwd: ${input.cwd}, projectPath: ${input.projectPath || "(not set)"}, mcpServers: ${mcpServersForSdk ? Object.keys(mcpServersForSdk).join(", ") : "(none)"}`) | ||||||||||||||||||||||
| if (finalCustomConfig) { | ||||||||||||||||||||||
| if (resolvedCustomConfig) { | ||||||||||||||||||||||
| const redactedConfig = { | ||||||||||||||||||||||
| ...finalCustomConfig, | ||||||||||||||||||||||
| token: `${finalCustomConfig.token.slice(0, 6)}...`, | ||||||||||||||||||||||
| ...resolvedCustomConfig, | ||||||||||||||||||||||
| token: resolvedCustomConfig.token | ||||||||||||||||||||||
| ? `${resolvedCustomConfig.token.slice(0, 6)}...` | ||||||||||||||||||||||
| : "", | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| if (isUsingOllama) { | ||||||||||||||||||||||
| console.log(`[Ollama] Using offline mode - Model: ${finalCustomConfig.model}, Base URL: ${finalCustomConfig.baseUrl}`) | ||||||||||||||||||||||
| console.log(`[Ollama] Using offline mode - Model: ${resolvedCustomConfig.model}, Base URL: ${resolvedCustomConfig.baseUrl}`) | ||||||||||||||||||||||
| } else { | ||||||||||||||||||||||
| console.log(`[claude] Custom config: ${JSON.stringify(redactedConfig)}`) | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| const resolvedModel = finalCustomConfig?.model || input.model | ||||||||||||||||||||||
| const resolvedModel = resolvedCustomConfig?.model || input.model | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| // DEBUG: If using Ollama, test if it's actually responding | ||||||||||||||||||||||
| if (isUsingOllama && finalCustomConfig) { | ||||||||||||||||||||||
| if (isUsingOllama && resolvedCustomConfig) { | ||||||||||||||||||||||
| console.log('[Ollama Debug] Testing Ollama connectivity...') | ||||||||||||||||||||||
| try { | ||||||||||||||||||||||
| const testResponse = await fetch(`${finalCustomConfig.baseUrl}/api/tags`, { | ||||||||||||||||||||||
| const testResponse = await fetch(`${resolvedCustomConfig.baseUrl}/api/tags`, { | ||||||||||||||||||||||
| signal: AbortSignal.timeout(2000) | ||||||||||||||||||||||
| }) | ||||||||||||||||||||||
| if (testResponse.ok) { | ||||||||||||||||||||||
| const data = await testResponse.json() | ||||||||||||||||||||||
| const models = data.models?.map((m: any) => m.name) || [] | ||||||||||||||||||||||
| console.log('[Ollama Debug] Ollama is responding. Available models:', models) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| if (!models.includes(finalCustomConfig.model)) { | ||||||||||||||||||||||
| console.error(`[Ollama Debug] WARNING: Model "${finalCustomConfig.model}" not found in Ollama!`) | ||||||||||||||||||||||
| if (!models.includes(resolvedCustomConfig.model)) { | ||||||||||||||||||||||
| console.error(`[Ollama Debug] WARNING: Model "${resolvedCustomConfig.model}" not found in Ollama!`) | ||||||||||||||||||||||
| console.error(`[Ollama Debug] Available models:`, models) | ||||||||||||||||||||||
| console.error(`[Ollama Debug] This will likely cause the stream to hang or fail silently.`) | ||||||||||||||||||||||
| } else { | ||||||||||||||||||||||
| console.log(`[Ollama Debug] ✓ Model "${finalCustomConfig.model}" is available`) | ||||||||||||||||||||||
| console.log(`[Ollama Debug] ✓ Model "${resolvedCustomConfig.model}" is available`) | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } else { | ||||||||||||||||||||||
| console.error('[Ollama Debug] Ollama returned error:', testResponse.status) | ||||||||||||||||||||||
|
|
@@ -1468,8 +1491,8 @@ ${prompt} | |||||||||||||||||||||
|
|
||||||||||||||||||||||
| if (isUsingOllama) { | ||||||||||||||||||||||
| console.log(`[Ollama] ===== STARTING STREAM ITERATION =====`) | ||||||||||||||||||||||
| console.log(`[Ollama] Model: ${finalCustomConfig?.model}`) | ||||||||||||||||||||||
| console.log(`[Ollama] Base URL: ${finalCustomConfig?.baseUrl}`) | ||||||||||||||||||||||
| console.log(`[Ollama] Model: ${resolvedCustomConfig?.model}`) | ||||||||||||||||||||||
| console.log(`[Ollama] Base URL: ${resolvedCustomConfig?.baseUrl}`) | ||||||||||||||||||||||
| console.log(`[Ollama] Prompt: "${typeof input.prompt === 'string' ? input.prompt.slice(0, 100) : 'N/A'}..."`) | ||||||||||||||||||||||
| console.log(`[Ollama] CWD: ${input.cwd}`) | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
@@ -1536,7 +1559,7 @@ ${prompt} | |||||||||||||||||||||
| console.error(`[CLAUDE SDK ERROR] CWD: ${input.cwd}`) | ||||||||||||||||||||||
| console.error(`[CLAUDE SDK ERROR] Mode: ${input.mode}`) | ||||||||||||||||||||||
| console.error(`[CLAUDE SDK ERROR] Session ID: ${msgAny.session_id || 'none'}`) | ||||||||||||||||||||||
| console.error(`[CLAUDE SDK ERROR] Has custom config: ${!!finalCustomConfig}`) | ||||||||||||||||||||||
| console.error(`[CLAUDE SDK ERROR] Has custom config: ${!!resolvedCustomConfig}`) | ||||||||||||||||||||||
| console.error(`[CLAUDE SDK ERROR] Is using Ollama: ${isUsingOllama}`) | ||||||||||||||||||||||
| console.error(`[CLAUDE SDK ERROR] Model: ${resolvedModel || 'default'}`) | ||||||||||||||||||||||
| console.error(`[CLAUDE SDK ERROR] Has OAuth token: ${!!claudeCodeToken}`) | ||||||||||||||||||||||
|
|
@@ -1794,7 +1817,7 @@ ${prompt} | |||||||||||||||||||||
| console.error(`[Ollama] 2. Model failed to start generating (check Ollama logs: ollama logs)`) | ||||||||||||||||||||||
| console.error(`[Ollama] 3. Network issue between Claude SDK and Ollama`) | ||||||||||||||||||||||
| console.error(`[Ollama] ===== NEXT STEPS =====`) | ||||||||||||||||||||||
| console.error(`[Ollama] 1. Check if model works: curl http://localhost:11434/api/generate -d '{"model":"${finalCustomConfig?.model}","prompt":"test"}'`) | ||||||||||||||||||||||
| console.error(`[Ollama] 1. Check if model works: curl http://localhost:11434/api/generate -d '{"model":"${resolvedCustomConfig?.model}","prompt":"test"}'`) | ||||||||||||||||||||||
| console.error(`[Ollama] 2. Check Ollama version supports Messages API`) | ||||||||||||||||||||||
| console.error(`[Ollama] 3. Try using a proxy that converts Anthropic API → Ollama format`) | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
@@ -2377,6 +2400,127 @@ ${prompt} | |||||||||||||||||||||
| return { success: true } | ||||||||||||||||||||||
| }), | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| fetchModels: publicProcedure | ||||||||||||||||||||||
| .input( | ||||||||||||||||||||||
| z.object({ | ||||||||||||||||||||||
| baseUrl: z.string().min(1), | ||||||||||||||||||||||
| token: z.string().optional(), | ||||||||||||||||||||||
| }), | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
| .mutation(async ({ input }) => { | ||||||||||||||||||||||
| const cleanUrl = input.baseUrl.replace(/\/$/, "") | ||||||||||||||||||||||
| const authToken = input.token ? decryptIfNeeded(input.token) : "" | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| const debugInfo: { | ||||||||||||||||||||||
| ollama?: { ok: boolean; status?: number; error?: string } | ||||||||||||||||||||||
| openai?: { ok: boolean; status?: number; error?: string } | ||||||||||||||||||||||
| } = {} | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| try { | ||||||||||||||||||||||
| const ollamaRes = await fetch(`${cleanUrl}/api/tags`) | ||||||||||||||||||||||
| debugInfo.ollama = { | ||||||||||||||||||||||
| ok: ollamaRes.ok, | ||||||||||||||||||||||
| status: ollamaRes.status, | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| if (ollamaRes.ok) { | ||||||||||||||||||||||
| const data = (await ollamaRes.json()) as { models?: Array<{ name?: string }> } | ||||||||||||||||||||||
| if (Array.isArray(data.models)) { | ||||||||||||||||||||||
| const models = data.models | ||||||||||||||||||||||
| .map((model: { name?: string }) => model.name) | ||||||||||||||||||||||
| .filter((name: string | undefined): name is string => Boolean(name)) | ||||||||||||||||||||||
| if (models.length > 0) { | ||||||||||||||||||||||
| return { | ||||||||||||||||||||||
| models, | ||||||||||||||||||||||
| status: { | ||||||||||||||||||||||
| success: true, | ||||||||||||||||||||||
| source: "ollama" as const, | ||||||||||||||||||||||
| details: debugInfo, | ||||||||||||||||||||||
| }, | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| debugInfo.ollama.error = "No models found in Ollama response" | ||||||||||||||||||||||
| } else { | ||||||||||||||||||||||
| debugInfo.ollama.error = "Ollama response missing 'models' array" | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } else { | ||||||||||||||||||||||
| debugInfo.ollama.error = `Ollama endpoint returned HTTP ${ollamaRes.status}` | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } catch (error) { | ||||||||||||||||||||||
| console.warn("[models] Failed to fetch Ollama tags:", error) | ||||||||||||||||||||||
| const message = error instanceof Error ? error.message : String(error) | ||||||||||||||||||||||
| debugInfo.ollama = { | ||||||||||||||||||||||
| ok: false, | ||||||||||||||||||||||
| error: `Failed to fetch Ollama tags: ${message}`, | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| try { | ||||||||||||||||||||||
| const res = await fetch(`${cleanUrl}/v1/models`, { | ||||||||||||||||||||||
| headers: authToken ? { Authorization: `Bearer ${authToken}` } : {}, | ||||||||||||||||||||||
| }) | ||||||||||||||||||||||
| debugInfo.openai = { | ||||||||||||||||||||||
| ok: res.ok, | ||||||||||||||||||||||
| status: res.status, | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| if (res.ok) { | ||||||||||||||||||||||
| const data = (await res.json()) as { data?: Array<{ id?: string }> } | ||||||||||||||||||||||
| if (Array.isArray(data.data)) { | ||||||||||||||||||||||
| const models = data.data | ||||||||||||||||||||||
| .map((model: { id?: string }) => model.id) | ||||||||||||||||||||||
| .filter((id: string | undefined): id is string => Boolean(id)) | ||||||||||||||||||||||
| if (models.length > 0) { | ||||||||||||||||||||||
| return { | ||||||||||||||||||||||
| models, | ||||||||||||||||||||||
| status: { | ||||||||||||||||||||||
| success: true, | ||||||||||||||||||||||
| source: "openai" as const, | ||||||||||||||||||||||
| details: debugInfo, | ||||||||||||||||||||||
| }, | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| debugInfo.openai.error = | ||||||||||||||||||||||
| "No models found in OpenAI-compatible response" | ||||||||||||||||||||||
| } else { | ||||||||||||||||||||||
| debugInfo.openai.error = | ||||||||||||||||||||||
| "OpenAI-compatible response missing 'data' array" | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } else { | ||||||||||||||||||||||
| debugInfo.openai.error = | ||||||||||||||||||||||
| `OpenAI-compatible endpoint returned HTTP ${res.status}` | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } catch (error) { | ||||||||||||||||||||||
| console.warn("[models] Failed to fetch OpenAI-compatible models:", error) | ||||||||||||||||||||||
| const message = error instanceof Error ? error.message : String(error) | ||||||||||||||||||||||
| debugInfo.openai = { | ||||||||||||||||||||||
| ok: false, | ||||||||||||||||||||||
| error: `Failed to fetch OpenAI-compatible models: ${message}`, | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| return { | ||||||||||||||||||||||
| models: [] as string[], | ||||||||||||||||||||||
| status: { | ||||||||||||||||||||||
| success: false, | ||||||||||||||||||||||
| source: "none" as const, | ||||||||||||||||||||||
| reason: | ||||||||||||||||||||||
| "Failed to fetch models from both Ollama and OpenAI-compatible endpoints", | ||||||||||||||||||||||
| details: debugInfo, | ||||||||||||||||||||||
| }, | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| }), | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| encryptToken: publicProcedure | ||||||||||||||||||||||
| .input( | ||||||||||||||||||||||
| z.object({ | ||||||||||||||||||||||
| token: z.string().min(1), | ||||||||||||||||||||||
| }), | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
| .mutation(({ input }) => { | ||||||||||||||||||||||
| const encrypted = safeStorage.isEncryptionAvailable() | ||||||||||||||||||||||
| ? safeStorage.encryptString(input.token).toString("base64") | ||||||||||||||||||||||
| : Buffer.from(input.token, "utf-8").toString("base64") | ||||||||||||||||||||||
|
Comment on lines
+2519
to
+2521
|
||||||||||||||||||||||
| const encrypted = safeStorage.isEncryptionAvailable() | |
| ? safeStorage.encryptString(input.token).toString("base64") | |
| : Buffer.from(input.token, "utf-8").toString("base64") | |
| if (!safeStorage.isEncryptionAvailable()) { | |
| throw new Error( | |
| "Secure token storage is not available on this system; cannot encrypt token.", | |
| ) | |
| } | |
| const encrypted = safeStorage.encryptString(input.token).toString("base64") |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The token field is optional (z.string().optional()) but the decryptIfNeeded function is called on it without checking if it exists first. While decryptIfNeeded does handle falsy values, the type signature suggests the token could be undefined. Consider making the validation more explicit by using .nullable() or adding a default value to make the intent clearer.