From 0f3b95e09f5b7e70502e210490345718fcd6634c Mon Sep 17 00:00:00 2001 From: emilsvennesson Date: Wed, 29 Apr 2026 21:35:58 +0200 Subject: [PATCH 1/3] refactor: simplify provider detection and resolution Detect providers by canonical OpenCode ID instead of layering npm-package matching, ID-substring hints, and per-model uniformity heuristics. The provider type now comes from a single flat ID-to-type lookup, with moonshotai-cn added alongside moonshotai. Collapses the 6-helper resolution chain to two functions, moves the ChatGPT-vs-OpenAI routing into detection, drops the redundant ProviderResolution.providerType field and ProviderState wrapper, scans providers once per cold path instead of three times, and extracts the duplicated chatgpt/copilot auth.json reading into a shared helper. Net -549 lines, no behavior change for any supported provider. --- .oxlintrc.json | 1 + AGENTS.md | 6 +- src/config.ts | 214 +++++------------ src/index.ts | 380 ++++++++----------------------- src/providers/anthropic/index.ts | 8 +- src/providers/chatgpt/auth.ts | 91 +------- src/providers/chatgpt/index.ts | 8 +- src/providers/copilot/auth.ts | 121 ++-------- src/providers/copilot/index.ts | 8 +- src/providers/index.ts | 17 +- src/providers/moonshot/index.ts | 8 +- src/providers/openai/index.ts | 8 +- src/providers/registry.ts | 127 +++-------- src/providers/shared/auth.ts | 82 +++++++ src/types.ts | 28 +-- 15 files changed, 320 insertions(+), 787 deletions(-) create mode 100644 src/providers/shared/auth.ts diff --git a/.oxlintrc.json b/.oxlintrc.json index f29c22a..afb5c2d 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -25,6 +25,7 @@ "max-statements": ["warn", { "max": 20 }], "max-params": ["warn", { "max": 5 }], "no-continue": "off", + "no-ternary": "off", "sort-imports": "off" }, "ignorePatterns": ["dist", "node_modules"] diff --git a/AGENTS.md b/AGENTS.md index d12cfff..571a6c1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,7 +81,7 @@ package.json # Bun-based project, ESM module ### Control flow -- **No ternary expressions.** Use `if`/`else` blocks. +- Ternary expressions are allowed when they make code clearer. Use `if`/`else` blocks for multi-statement branches. - Always use **braces** with `if`/`else`/`for`/`while` (enforced by `curly`). - Prefer `continue` in loops to skip irrelevant iterations and reduce nesting. @@ -130,7 +130,7 @@ oxlint with plugins: `unicorn`, `typescript`, `import`, `oxc`. Key rules that shape the code: - `no-magic-numbers`, `max-statements` (20), `max-params` (5) -- `no-ternary`, `curly`, `sort-keys`, `sort-imports` +- `curly`, `sort-keys`, `sort-imports` - `func-style` (expressions only), `init-declarations`, `id-length` (min 2) - `unicorn/catch-error-name`, `unicorn/text-encoding-identifier-case` - `typescript/no-unused-vars` (error) @@ -142,7 +142,7 @@ Disabled rules (with rationale): - `unicorn/filename-case` -- PascalCase not enforced on filenames - `unicorn/prevent-abbreviations` -- short names like `ctx`, `env`, `url` are clear - `no-continue` -- `continue` is cleaner than empty if-branches or deep nesting in loops -- `unicorn/prefer-ternary` -- conflicts with `no-ternary` +- `unicorn/prefer-ternary` -- ternaries are allowed but not required - `import/no-nodejs-modules` -- this is a Node.js plugin; fs/os/path are required - `import/no-named-export` -- internal modules use named exports - `import/prefer-default-export` -- internal modules use named exports, not default diff --git a/src/config.ts b/src/config.ts index c14df2d..d9dc348 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,9 +2,9 @@ import { ProviderCredentials, ProviderResolution, ProviderResolutionMap, - ProviderType, + ScannableProviderType, } from "./types.js"; -import { detectProviderTypeFromModel } from "./providers/registry.js"; +import { SCANNABLE_TYPES, detectProviderType } from "./providers/registry.js"; // ── Types ────────────────────────────────────────────────────────────── @@ -17,32 +17,19 @@ interface ProviderData { interface ProviderModel { api: { - npm: string; url?: string; }; id: string; options: Record; } -interface ProviderModelOverrides { - fallbackModel?: string; - lockedModel?: string; -} - interface ScanResult { credentials: ProviderCredentials | null; fallbackModel?: string; lockedModel?: string; } -interface ScanState { - anthropic: ScanResult; - copilot: ScanResult; - moonshot: ScanResult; - openai: ScanResult; -} - -type ScannableProviderType = Exclude; +type ScanState = Record; // ── Constants ────────────────────────────────────────────────────────── @@ -60,65 +47,38 @@ const getWebsearchOption = (model: ProviderModel): string | null => { return null; }; -const normalizeBaseURL = (url: string): string => url.replace(/\/v1\/?$/, ""); - -const extractApiKey = (options: Record): string | undefined => { - if (typeof options.apiKey !== "string") { - return undefined; - } - - return options.apiKey; -}; - -const extractConfiguredBaseURL = (options: Record): string | undefined => { - if (typeof options.baseURL !== "string") { - return undefined; - } - - return options.baseURL; -}; +const stripV1Suffix = (url: string): string => url.replace(/\/v1\/?$/, ""); -const normalizeProviderBaseURL = (providerType: ProviderType, baseURL: string): string => { - if (providerType === "anthropic") { - return normalizeBaseURL(baseURL); - } +const extractApiKey = (options: Record): string | undefined => + typeof options.apiKey === "string" ? options.apiKey : undefined; - return baseURL; -}; +const extractStringOption = (options: Record, key: string): string | undefined => + typeof options[key] === "string" ? (options[key] as string) : undefined; -const extractModelBaseURL = (model: ProviderModel): string | undefined => { - if (typeof model.api.url !== "string") { - return undefined; - } - - return model.api.url; -}; +const normalizeBaseURL = (providerType: ScannableProviderType, baseURL: string): string => + providerType === "anthropic" ? stripV1Suffix(baseURL) : baseURL; const resolveBaseURL = ( provider: ProviderData, - providerType: ProviderType, + providerType: ScannableProviderType, model: ProviderModel, ): string | undefined => { - const configuredBaseURL = extractConfiguredBaseURL(provider.options); - if (configuredBaseURL) { - return normalizeProviderBaseURL(providerType, configuredBaseURL); + const configured = extractStringOption(provider.options, "baseURL"); + if (configured) { + return normalizeBaseURL(providerType, configured); } - const modelBaseURL = extractModelBaseURL(model); - if (!modelBaseURL) { + const modelURL = model.api.url; + if (typeof modelURL !== "string") { return undefined; } - if (providerType === "anthropic") { - return normalizeBaseURL(modelBaseURL); - } - - return modelBaseURL; + return normalizeBaseURL(providerType, modelURL); }; const extractCredentials = ( provider: ProviderData, - providerType: ProviderType, + providerType: ScannableProviderType, model: ProviderModel, ): ProviderCredentials | null => { const apiKey = provider.key ?? extractApiKey(provider.options); @@ -129,22 +89,8 @@ const extractCredentials = ( return { apiKey, baseURL: resolveBaseURL(provider, providerType, model) }; }; -const createInitialScanState = (): ScanState => ({ - anthropic: { credentials: null }, - copilot: { credentials: null }, - moonshot: { credentials: null }, - openai: { credentials: null }, -}); - -const isScannableProviderType = ( - providerType: ProviderType, -): providerType is ScannableProviderType => { - if (providerType === "chatgpt") { - return false; - } - - return true; -}; +const createInitialScanState = (): ScanState => + Object.fromEntries(SCANNABLE_TYPES.map((type) => [type, { credentials: null }])) as ScanState; const updateModelsFromWebsearchOption = (scan: ScanResult, model: ProviderModel): void => { const option = getWebsearchOption(model); @@ -160,20 +106,9 @@ const updateModelsFromWebsearchOption = (scan: ScanResult, model: ProviderModel) const processProviderModel = ( provider: ProviderData, model: ProviderModel, + providerType: ScannableProviderType, state: ScanState, ): void => { - const providerType = detectProviderTypeFromModel({ - model, - providerID: provider.id, - }); - if (!providerType) { - return; - } - - if (!isScannableProviderType(providerType)) { - return; - } - const scan = state[providerType]; const candidate = extractCredentials(provider, providerType, model); if (!scan.credentials) { @@ -186,25 +121,17 @@ const processProviderModel = ( }; const scanProvider = (provider: ProviderData, state: ScanState): void => { - for (const model of Object.values(provider.models)) { - processProviderModel(provider, model, state); + const providerType = detectProviderType(provider.id); + if (!providerType) { + return; } -}; - -const scanProviders = (providers: ProviderData[]): ScanState => { - const state = createInitialScanState(); - for (const provider of providers) { - scanProvider(provider, state); + for (const model of Object.values(provider.models)) { + processProviderModel(provider, model, providerType, state); } - - return state; }; -const buildResolution = ( - scan: ScanResult, - providerType: ScannableProviderType, -): ProviderResolution | null => { +const buildResolution = (scan: ScanResult): ProviderResolution | null => { if (!scan.credentials) { return null; } @@ -213,76 +140,46 @@ const buildResolution = ( credentials: scan.credentials, fallbackModel: scan.fallbackModel, lockedModel: scan.lockedModel, - providerType, }; }; -const buildResolutionMap = (state: ScanState): ProviderResolutionMap => { - const result: ProviderResolutionMap = {}; - - const anthropic = buildResolution(state.anthropic, "anthropic"); - if (anthropic) { - result.anthropic = anthropic; - } - - const openai = buildResolution(state.openai, "openai"); - if (openai) { - result.openai = openai; - } +// ── Public API ───────────────────────────────────────────────────────── - const moonshot = buildResolution(state.moonshot, "moonshot"); - if (moonshot) { - result.moonshot = moonshot; - } +/** + * Scan all providers in one pass, recording credentials and any + * `websearch`-tagged models per scannable provider type. + */ +const scanProviders = (providers: ProviderData[]): ScanState => { + const state = createInitialScanState(); - const copilot = buildResolution(state.copilot, "copilot"); - if (copilot) { - result.copilot = copilot; + for (const provider of providers) { + scanProvider(provider, state); } - return result; -}; - -const buildModelOverrides = (scan: ScanResult): ProviderModelOverrides => ({ - fallbackModel: scan.fallbackModel, - lockedModel: scan.lockedModel, -}); - -// ── Config resolution ────────────────────────────────────────────────── - -const resolveModelOverrides = ( - providers: ProviderData[], - providerType: ScannableProviderType, -): ProviderModelOverrides => { - const state = scanProviders(providers); - - return buildModelOverrides(state[providerType]); + return state; }; /** - * Scan providers for Anthropic, OpenAI, Moonshot, and GitHub Copilot credentials - * and any websearch-tagged models. - * - * Resolution priority (per provider): - * - `lockedModel`: first model with `"websearch": "always"` (hard lock) - * - `fallbackModel`: first model with `"websearch": "auto"` (soft fallback) - * - `credentials`: API key + optional baseURL from the first matching provider - * - * Returns a map with optional entries for each supported provider type. + * Build the resolution map from a previously computed scan state. + * Only providers with credentials are emitted. */ -const resolveFromProviders = (providers: ProviderData[]): ProviderResolutionMap => { - const state = scanProviders(providers); +const buildResolutionMap = (state: ScanState): ProviderResolutionMap => { + const result: ProviderResolutionMap = {}; - return buildResolutionMap(state); + for (const type of SCANNABLE_TYPES) { + const resolution = buildResolution(state[type]); + if (resolution) { + result[type] = resolution; + } + } + + return result; }; // ── Error formatting ─────────────────────────────────────────────────── -const resolveGeneralModelHint = (): string => - "claude-sonnet-4-6, claude-opus-4-6, gpt-5.4, gpt-5.4-mini, kimi-k2.6"; - -const resolveCopilotModelHint = (): string => - "gpt-5.3-codex, gpt-5.2-codex, gpt-5.2, gpt-5.1, gpt-5.4-mini"; +const GENERAL_MODEL_HINT = "claude-sonnet-4-6, claude-opus-4-6, gpt-5.4, gpt-5.4-mini, kimi-k2.6"; +const COPILOT_MODEL_HINT = "gpt-5.3-codex, gpt-5.2-codex, gpt-5.2, gpt-5.1, gpt-5.4-mini"; const formatNoProviderError = (): string => `Error: web-search requires an Anthropic, OpenAI (API key or ChatGPT OAuth), Moonshot, or GitHub Copilot provider. @@ -335,10 +232,10 @@ const formatUnsupportedProviderError = (activeModelID: string): string => Web search requires an Anthropic, OpenAI, Moonshot, or GitHub Copilot web-search-capable model. -Known Copilot models that work with web search today include: ${resolveCopilotModelHint()}. +Known Copilot models that work with web search today include: ${COPILOT_MODEL_HINT}. You can either: -1. Switch to a supported model (e.g. ${resolveGeneralModelHint()}) +1. Switch to a supported model (e.g. ${GENERAL_MODEL_HINT}) 2. Set \`"websearch": "auto"\` on a supported model to use it as a fallback: { @@ -358,9 +255,10 @@ You can either: Or set \`"websearch": "always"\` to always use that model for web search regardless of your active model.`; export { + buildResolutionMap, formatNoProviderError, formatUnsupportedProviderError, ProviderData, - resolveModelOverrides, - resolveFromProviders, + ScanState, + scanProviders, }; diff --git a/src/index.ts b/src/index.ts index e6a2bc8..7b6e131 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,352 +1,150 @@ -import { - ActiveModel, - ProviderResolution, - ProviderResolutionMap, - ProviderType, - SearchConfig, -} from "./types.js"; -import { Plugin, tool } from "@opencode-ai/plugin"; +import { ActiveModel, ProviderResolution, ProviderResolutionMap, ProviderType } from "./types.js"; +import { Plugin, PluginInput, tool } from "@opencode-ai/plugin"; import { ProviderData, + buildResolutionMap, formatNoProviderError, formatUnsupportedProviderError, - resolveFromProviders, - resolveModelOverrides, + scanProviders, } from "./config.js"; +import { RESOLUTION_PRIORITY, detectProviderType } from "./providers/registry.js"; +import { dispatchErrorMessage, dispatchSearch } from "./providers/index.js"; import { getCurrentMonthYear } from "./helpers.js"; import { resolveChatGPTCredentials } from "./providers/chatgpt/auth.js"; import { resolveCopilotCredentials } from "./providers/copilot/auth.js"; -import { dispatchErrorMessage, dispatchSearch } from "./providers/index.js"; -import { - RESOLUTION_PRIORITY, - detectProviderTypeFromModel, - detectProviderTypeFromProviderID, -} from "./providers/registry.js"; // ── Constants ────────────────────────────────────────────────────────── const MIN_QUERY_LENGTH = 2; +const NO_PROVIDERS = 0; -// ── Provider detection ───────────────────────────────────────────────── - -const detectTypeFromProviderModel = ( - provider: ProviderData, - model: ProviderData["models"][string], -): ProviderType | null => - detectProviderTypeFromModel({ - model, - providerID: provider.id, - }); - -const detectUniformProviderType = (provider: ProviderData): ProviderType | null => { - let detectedType: ProviderType | null = null; - - for (const model of Object.values(provider.models)) { - const modelType = detectTypeFromProviderModel(provider, model); - if (!modelType) { - continue; - } - - if (!detectedType) { - detectedType = modelType; - continue; - } - - if (detectedType !== modelType) { - return null; - } - } - - return detectedType; -}; - -const detectProviderTypeFromProviderModels = ( - provider: ProviderData, - activeModelID: string, -): ProviderType | null => { - for (const model of Object.values(provider.models)) { - if (model.id !== activeModelID) { - continue; - } - - const modelType = detectTypeFromProviderModel(provider, model); - if (modelType) { - return modelType; - } - } - - return detectUniformProviderType(provider); -}; - -const detectTypeFromActiveProvider = ( - active: ActiveModel, - providers: ProviderData[], -): ProviderType | null => { - for (const provider of providers) { - if (provider.id !== active.providerID) { - continue; - } - - const modelsType = detectProviderTypeFromProviderModels(provider, active.modelID); - if (modelsType) { - return modelsType; - } - - const providerType = detectProviderTypeFromProviderID(provider.id); - if (providerType) { - return providerType; - } - } - - return null; -}; +// ── Types ────────────────────────────────────────────────────────────── -const detectTypeFromAnyModelMatch = ( - activeModelID: string, - providers: ProviderData[], -): ProviderType | null => { - for (const provider of providers) { - for (const model of Object.values(provider.models)) { - if (model.id !== activeModelID) { - continue; - } - - const modelType = detectTypeFromProviderModel(provider, model); - if (modelType) { - return modelType; - } - } - } +interface PickedModel { + modelID: string; + resolution: ProviderResolution; + type: ProviderType; +} - return null; -}; +// ── Provider detection ───────────────────────────────────────────────── -const detectActiveProviderType = ( +/** + * Resolve which adapter type to use for the active model. The user is on + * the canonical OpenCode provider (e.g. `openai`), but if ChatGPT OAuth + * is present we route OpenAI traffic through the ChatGPT adapter. + */ +const resolveActiveType = ( active: ActiveModel | undefined, - providers: ProviderData[], + resolutions: ProviderResolutionMap, ): ProviderType | null => { if (!active) { return null; } - const activeProviderType = detectTypeFromActiveProvider(active, providers); - if (activeProviderType) { - return activeProviderType; - } - - const modelMatchType = detectTypeFromAnyModelMatch(active.modelID, providers); - if (modelMatchType) { - return modelMatchType; - } + const base = detectProviderType(active.providerID); - return detectProviderTypeFromProviderID(active.providerID); + return base === "openai" && resolutions.chatgpt ? "chatgpt" : base; }; -const hasConfiguredOpenAIBaseURL = (resolutions: ProviderResolutionMap): boolean => { - const openaiResolution = resolutions.openai; - if (!openaiResolution) { - return false; - } - - const { baseURL } = openaiResolution.credentials; - if (typeof baseURL !== "string") { - return false; - } - - return baseURL.trim() !== ""; -}; - -const shouldAttachChatGPTResolution = (resolutions: ProviderResolutionMap): boolean => - !hasConfiguredOpenAIBaseURL(resolutions); - // ── Model resolution ─────────────────────────────────────────────────── -interface ResolvedProvider { - config: SearchConfig; - providerType: ProviderType; -} - -const buildSearchConfig = (resolution: ProviderResolution, modelID: string): SearchConfig => ({ - accountId: resolution.credentials.accountId, - apiKey: resolution.credentials.apiKey, - baseURL: resolution.credentials.baseURL, - model: modelID, -}); - -const resolveModelByPriority = ( - resolutions: ProviderResolutionMap, - modelKey: "fallbackModel" | "lockedModel", -): ResolvedProvider | null => { - for (const providerType of RESOLUTION_PRIORITY) { - const resolution = resolutions[providerType]; - if (!resolution) { - continue; - } - - const modelID = resolution[modelKey]; - if (!modelID) { - continue; - } - - return { - config: buildSearchConfig(resolution, modelID), - providerType, - }; - } - - return null; -}; - /** - * Resolve the locked model for a given provider resolution. - * Returns a ResolvedProvider if a locked model is set, otherwise null. + * Walk providers in priority order looking for one that has the given + * model key (`lockedModel` or `fallbackModel`) set. */ -const resolveLockedModel = (resolutions: ProviderResolutionMap): ResolvedProvider | null => - resolveModelByPriority(resolutions, "lockedModel"); - -/** - * Resolve using the active model's provider directly. - */ -const resolveActiveModel = ( - activeType: ProviderType, - active: ActiveModel, +const findModelByKey = ( resolutions: ProviderResolutionMap, -): ResolvedProvider | null => { - if (activeType === "openai" && resolutions.chatgpt) { - return { - config: buildSearchConfig(resolutions.chatgpt, active.modelID), - providerType: "chatgpt", - }; - } - - const resolution = resolutions[activeType]; - if (!resolution) { - return null; + key: "fallbackModel" | "lockedModel", +): PickedModel | null => { + for (const type of RESOLUTION_PRIORITY) { + const resolution = resolutions[type]; + const modelID = resolution?.[key]; + if (resolution && modelID) { + return { modelID, resolution, type }; + } } - return { - config: buildSearchConfig(resolution, active.modelID), - providerType: activeType, - }; + return null; }; /** - * Resolve a fallback model from any provider with `"websearch": "auto"`. - */ -const resolveFallbackModel = (resolutions: ProviderResolutionMap): ResolvedProvider | null => - resolveModelByPriority(resolutions, "fallbackModel"); - -/** - * Determine which provider and model to use for a web search call. + * Pick the model and provider to use for a web search call. * * Priority: - * 1. Locked model (`"websearch": "always"`) from any provider — always wins - * 2. Active model if it belongs to a supported provider — use directly - * 3. Fallback model (`"websearch": "auto"`) from any provider — when active is unsupported - * 4. null — no usable provider/model found + * 1. Locked model (`"websearch": "always"`) on any provider + * 2. Active model if its provider is supported + * 3. Fallback model (`"websearch": "auto"`) on any provider */ -const resolveSearchProvider = ( +const pickModel = ( resolutions: ProviderResolutionMap, active: ActiveModel | undefined, activeType: ProviderType | null, -): ResolvedProvider | null => { - const locked = resolveLockedModel(resolutions); +): PickedModel | null => { + const locked = findModelByKey(resolutions, "lockedModel"); if (locked) { return locked; } - if (activeType && active) { - const resolved = resolveActiveModel(activeType, active, resolutions); - if (resolved) { - return resolved; + if (active && activeType) { + const resolution = resolutions[activeType]; + if (resolution) { + return { modelID: active.modelID, resolution, type: activeType }; } } - return resolveFallbackModel(resolutions); + return findModelByKey(resolutions, "fallbackModel"); }; -// ── Lazy provider resolution ─────────────────────────────────────────── +// ── Resolution loading ───────────────────────────────────────────────── -interface ProviderState { - list: ProviderData[]; - resolutions: ProviderResolutionMap; -} - -const resolveProviderState = async ( - client: { - config: { providers: () => Promise<{ data?: { providers: unknown[] } }> }; - path: { - get: (options?: { query?: { directory?: string } }) => Promise<{ data?: { state?: string } }>; - }; - }, +const loadResolutions = async ( + client: PluginInput["client"], directory: string, -): Promise => { +): Promise => { const { data } = await client.config.providers(); if (!data) { - return { list: [], resolutions: {} }; + return {}; } const list = data.providers as ProviderData[]; - const resolutions = resolveFromProviders(list); - - const chatgptCredentials = await resolveChatGPTCredentials(client, directory); - if (chatgptCredentials && shouldAttachChatGPTResolution(resolutions)) { - const modelOverrides = resolveModelOverrides(list, "openai"); + const scan = scanProviders(list); + const resolutions = buildResolutionMap(scan); + + /* + * ChatGPT OAuth supersedes the OpenAI provider unless the user has explicitly + * pointed `openai` at a custom baseURL (e.g. a proxy). + */ + const chatgpt = await resolveChatGPTCredentials(client, directory); + const openaiBaseURL = resolutions.openai?.credentials.baseURL?.trim(); + if (chatgpt && !openaiBaseURL) { resolutions.chatgpt = { - credentials: { - accountId: chatgptCredentials.accountId, - apiKey: chatgptCredentials.apiKey, - baseURL: chatgptCredentials.baseURL, - }, - fallbackModel: modelOverrides.fallbackModel, - lockedModel: modelOverrides.lockedModel, - providerType: "chatgpt", + credentials: chatgpt, + fallbackModel: scan.openai.fallbackModel, + lockedModel: scan.openai.lockedModel, }; } - const copilotCredentials = await resolveCopilotCredentials(client, directory); - if (copilotCredentials) { - const modelOverrides = resolveModelOverrides(list, "copilot"); + /* + * Copilot credentials live in OpenCode's auth.json, not in opencode.json, + * but websearch flags can still be set on the `github-copilot` provider. + */ + const copilot = await resolveCopilotCredentials(client, directory); + if (copilot) { resolutions.copilot = { - credentials: copilotCredentials, - fallbackModel: modelOverrides.fallbackModel, - lockedModel: modelOverrides.lockedModel, - providerType: "copilot", + credentials: copilot, + fallbackModel: scan.copilot.fallbackModel, + lockedModel: scan.copilot.lockedModel, }; } - return { list, resolutions }; -}; - -const hasAnyProvider = (resolutions: ProviderResolutionMap): boolean => { - if (resolutions.anthropic) { - return true; - } - - if (resolutions.chatgpt) { - return true; - } - - if (resolutions.openai) { - return true; - } - - if (resolutions.moonshot) { - return true; - } - - if (resolutions.copilot) { - return true; - } - - return false; + return resolutions; }; // ── Plugin ───────────────────────────────────────────────────────────── // oxlint-disable-next-line import/no-default-export -- plugin entry point requires default export export default (async (input) => { - let state: ProviderState | null = null; + let resolutions: ProviderResolutionMap | null = null; const activeModels = new Map(); return { @@ -388,26 +186,26 @@ IMPORTANT - Use the correct year in search queries: - Example: If the user asks for "latest React docs", search for "React documentation" with the current year, NOT last year`, async execute(args, context) { - if (!state) { - state = await resolveProviderState(input.client, input.directory); - } - - if (!hasAnyProvider(state.resolutions)) { - return formatNoProviderError(); - } + resolutions ??= await loadResolutions(input.client, input.directory); const active = activeModels.get(context.sessionID); - const activeType = detectActiveProviderType(active, state.list); - const resolved = resolveSearchProvider(state.resolutions, active, activeType); + const activeType = resolveActiveType(active, resolutions); + const picked = pickModel(resolutions, active, activeType); - if (!resolved) { - return formatUnsupportedProviderError(active?.modelID ?? "unknown"); + if (!picked) { + return Object.keys(resolutions).length === NO_PROVIDERS + ? formatNoProviderError() + : formatUnsupportedProviderError(active?.modelID ?? "unknown"); } try { - return await dispatchSearch(resolved.providerType, resolved.config, args.query); + return await dispatchSearch( + picked.type, + { ...picked.resolution.credentials, model: picked.modelID }, + args.query, + ); } catch (error) { - return dispatchErrorMessage(resolved.providerType, error); + return dispatchErrorMessage(picked.type, error); } }, }), diff --git a/src/providers/anthropic/index.ts b/src/providers/anthropic/index.ts index 4e181d1..e4bccd0 100644 --- a/src/providers/anthropic/index.ts +++ b/src/providers/anthropic/index.ts @@ -6,7 +6,7 @@ import { SEARCH_SYSTEM_PROMPT, buildSearchInput, } from "../shared/search.js"; -import { SearchArgs, SearchConfig, SearchHit, StructuredSearchResponse } from "../../types.js"; +import { SearchConfig, SearchHit, StructuredSearchResponse } from "../../types.js"; import { formatUnhandledSearchError } from "../shared/errors.js"; // ── Anthropic-specific types ──────────────────────────────────────────── @@ -107,7 +107,7 @@ const createAnthropicClient = (config: SearchConfig): Anthropic => { return new Anthropic(options); }; -const executeSearch = async (config: SearchConfig, args: SearchArgs): Promise => { +const executeSearch = async (config: SearchConfig, query: string): Promise => { const client = createAnthropicClient(config); const webSearchTool = buildWebSearchTool(); @@ -115,7 +115,7 @@ const executeSearch = async (config: SearchConfig, args: SearchArgs): Promise Promise<{ data?: { state?: string } }>; - }; -} - // ── Constants ────────────────────────────────────────────────────────── -const AUTH_FILE_NAME = "auth.json"; -const EMPTY_LENGTH = 0; const OPENAI_AUTH_KEY = "openai"; -const OPENCODE_DIR = "opencode"; -const SHARE_DIR = "share"; -const STATE_DIR = "state"; // ── Helpers ──────────────────────────────────────────────────────────── -const resolveAuthPathFromStatePath = (statePath: string | undefined): string | null => { - if (!statePath) { - return null; - } - - const stateMarker = `${sep}${STATE_DIR}${sep}${OPENCODE_DIR}`; - if (!statePath.endsWith(stateMarker)) { - return null; - } - - const dataMarker = `${sep}${SHARE_DIR}${sep}${OPENCODE_DIR}`; - const dataPath = `${statePath.slice(EMPTY_LENGTH, -stateMarker.length)}${dataMarker}`; - return join(dataPath, AUTH_FILE_NAME); -}; - -const resolveAuthPath = async (client: PathClient, directory: string): Promise => { - const response = await client.path.get({ query: { directory } }); - if (!response.data) { - return null; - } - - const statePath = response.data.state; - if (typeof statePath !== "string") { - return null; - } - - return resolveAuthPathFromStatePath(statePath); -}; - -const parseAuthStore = (content: string): Record | null => { - try { - const parsed = JSON.parse(content) as unknown; - if (parsed && typeof parsed === "object") { - return parsed as Record; - } - return null; - } catch { - return null; - } -}; - -const readOpenAIEntry = (authPath: string): ChatGPTAuthEntry | null => { - if (!existsSync(authPath)) { - return null; - } - - const content = readFileSync(authPath, "utf8"); - const authStore = parseAuthStore(content); - if (!authStore) { - return null; - } - - const candidate = authStore[OPENAI_AUTH_KEY]; - if (!candidate || typeof candidate !== "object") { - return null; - } - - return candidate as ChatGPTAuthEntry; -}; - const buildCredentials = (entry: ChatGPTAuthEntry): ChatGPTCredentials | null => { if (entry.type !== "oauth") { return null; } - if (typeof entry.access !== "string" || entry.access.length === EMPTY_LENGTH) { + if (typeof entry.access !== "string" || !entry.access) { return null; } - if (typeof entry.accountId !== "string" || entry.accountId.length === EMPTY_LENGTH) { + if (typeof entry.accountId !== "string" || !entry.accountId) { return null; } @@ -118,17 +45,9 @@ const resolveChatGPTCredentials = async ( client: PathClient, directory: string, ): Promise => { - const authPath = await resolveAuthPath(client, directory); - if (!authPath) { - return null; - } - - const entry = readOpenAIEntry(authPath); - if (!entry) { - return null; - } + const entry = await readAuthEntry(client, directory, OPENAI_AUTH_KEY); - return buildCredentials(entry); + return entry ? buildCredentials(entry) : null; }; export { ChatGPTCredentials, resolveChatGPTCredentials }; diff --git a/src/providers/chatgpt/index.ts b/src/providers/chatgpt/index.ts index 34250e5..2c13dd9 100644 --- a/src/providers/chatgpt/index.ts +++ b/src/providers/chatgpt/index.ts @@ -5,7 +5,7 @@ import { EMPTY_LENGTH, SEARCH_SYSTEM_PROMPT, } from "../shared/search.js"; -import { SearchArgs, SearchConfig, SearchHit } from "../../types.js"; +import { SearchConfig, SearchHit } from "../../types.js"; import { CHATGPT_DEFAULT_BASE_URL, CHATGPT_USER_AGENT } from "./constants.js"; // ── Types ────────────────────────────────────────────────────────────── @@ -265,9 +265,9 @@ const readStreamResponse = async (response: Response): Promise => { // ── Execution ────────────────────────────────────────────────────────── -const executeSearch = async (config: SearchConfig, args: SearchArgs): Promise => { +const executeSearch = async (config: SearchConfig, query: string): Promise => { const response = await fetch(resolveResponsesURL(config.baseURL), { - body: JSON.stringify(buildRequestBody(config, args.query)), + body: JSON.stringify(buildRequestBody(config, query)), headers: buildDefaultHeaders(config.accountId, config.apiKey), method: "POST", }); @@ -277,7 +277,7 @@ const executeSearch = async (config: SearchConfig, args: SearchArgs): Promise Promise<{ data?: { state?: string } }>; - }; -} - // ── Constants ────────────────────────────────────────────────────────── -const AUTH_FILE_NAME = "auth.json"; const COPILOT_AUTH_KEY = "github-copilot"; -const EMPTY_LENGTH = 0; -const OPENCODE_DIR = "opencode"; -const REMOVE_LAST_CHARACTER = -1; -const SHARE_DIR = "share"; -const STATE_DIR = "state"; +const HTTP_PREFIX = "http://"; +const HTTPS_PREFIX = "https://"; +const STRIP_LAST_CHAR = -1; +const STRING_START = 0; // ── Helpers ──────────────────────────────────────────────────────────── -const normalizeDomain = (value: string): string => { - let domain = value.trim(); - - if (domain.startsWith("https://")) { - domain = domain.slice("https://".length); - } else if (domain.startsWith("http://")) { - domain = domain.slice("http://".length); +const stripScheme = (value: string): string => { + if (value.startsWith(HTTPS_PREFIX)) { + return value.slice(HTTPS_PREFIX.length); } - - if (domain.endsWith("/")) { - domain = domain.slice(EMPTY_LENGTH, REMOVE_LAST_CHARACTER); + if (value.startsWith(HTTP_PREFIX)) { + return value.slice(HTTP_PREFIX.length); } + return value; +}; - return domain; +const normalizeDomain = (value: string): string => { + const stripped = stripScheme(value.trim()); + return stripped.endsWith("/") ? stripped.slice(STRING_START, STRIP_LAST_CHAR) : stripped; }; const buildCopilotBaseURL = (enterpriseUrl: string | undefined): string => { @@ -56,78 +45,16 @@ const buildCopilotBaseURL = (enterpriseUrl: string | undefined): string => { } const domain = normalizeDomain(enterpriseUrl); - if (domain.length === EMPTY_LENGTH) { - return COPILOT_DEFAULT_BASE_URL; - } - return `https://copilot-api.${domain}`; -}; - -const resolveAuthPathFromStatePath = (statePath: string | undefined): string | null => { - if (!statePath) { - return null; - } - - const stateMarker = `${sep}${STATE_DIR}${sep}${OPENCODE_DIR}`; - if (!statePath.endsWith(stateMarker)) { - return null; - } - - const dataMarker = `${sep}${SHARE_DIR}${sep}${OPENCODE_DIR}`; - const dataPath = `${statePath.slice(EMPTY_LENGTH, -stateMarker.length)}${dataMarker}`; - return join(dataPath, AUTH_FILE_NAME); -}; - -const resolveAuthPath = async (client: PathClient, directory: string): Promise => { - const response = await client.path.get({ query: { directory } }); - if (!response.data) { - return null; - } - - const statePath = response.data.state; - if (typeof statePath !== "string") { - return null; - } - - return resolveAuthPathFromStatePath(statePath); -}; - -const parseAuthStore = (content: string): Record | null => { - try { - const parsed = JSON.parse(content) as unknown; - if (parsed && typeof parsed === "object") { - return parsed as Record; - } - return null; - } catch { - return null; - } -}; - -const readCopilotEntry = (authPath: string): CopilotAuthEntry | null => { - if (!existsSync(authPath)) { - return null; - } - - const content = readFileSync(authPath, "utf8"); - const authStore = parseAuthStore(content); - if (!authStore) { - return null; - } - - const candidate = authStore[COPILOT_AUTH_KEY]; - if (!candidate || typeof candidate !== "object") { - return null; - } - - return candidate as CopilotAuthEntry; + return domain ? `https://copilot-api.${domain}` : COPILOT_DEFAULT_BASE_URL; }; const buildCredentials = (entry: CopilotAuthEntry): CopilotCredentials | null => { if (entry.type !== "oauth") { return null; } - if (typeof entry.refresh !== "string" || entry.refresh.length === EMPTY_LENGTH) { + + if (typeof entry.refresh !== "string" || !entry.refresh) { return null; } @@ -141,17 +68,9 @@ const resolveCopilotCredentials = async ( client: PathClient, directory: string, ): Promise => { - const authPath = await resolveAuthPath(client, directory); - if (!authPath) { - return null; - } - - const entry = readCopilotEntry(authPath); - if (!entry) { - return null; - } + const entry = await readAuthEntry(client, directory, COPILOT_AUTH_KEY); - return buildCredentials(entry); + return entry ? buildCredentials(entry) : null; }; export { resolveCopilotCredentials }; diff --git a/src/providers/copilot/index.ts b/src/providers/copilot/index.ts index 749eda9..d1c2a78 100644 --- a/src/providers/copilot/index.ts +++ b/src/providers/copilot/index.ts @@ -11,7 +11,7 @@ import { } from "../shared/search.js"; import { formatUnhandledSearchError } from "../shared/errors.js"; import OpenAI, { APIError } from "openai"; -import { SearchArgs, SearchConfig } from "../../types.js"; +import { SearchConfig } from "../../types.js"; import { COPILOT_INITIATOR, COPILOT_INTENT, COPILOT_USER_AGENT } from "./constants.js"; // ── Constants ────────────────────────────────────────────────────────── @@ -33,7 +33,7 @@ const formatErrorMessage = (error: unknown): string => { // ── Client and execution ─────────────────────────────────────────────── -const executeSearch = async (config: SearchConfig, args: SearchArgs): Promise => { +const executeSearch = async (config: SearchConfig, query: string): Promise => { const client = createOpenAICompatibleClient(config, { "Openai-Intent": COPILOT_INTENT, "User-Agent": COPILOT_USER_AGENT, @@ -42,7 +42,7 @@ const executeSearch = async (config: SearchConfig, args: SearchArgs): Promise Promise; + executeSearch: (config: SearchConfig, query: string) => Promise; formatErrorMessage: (error: unknown) => string; } @@ -58,16 +58,9 @@ const dispatchSearch = async ( providerType: ProviderType, config: SearchConfig, query: string, -): Promise => { - const adapter = PROVIDER_ADAPTERS[providerType]; +): Promise => PROVIDER_ADAPTERS[providerType].executeSearch(config, query); - return adapter.executeSearch(config, { query }); -}; - -const dispatchErrorMessage = (providerType: ProviderType, error: unknown): string => { - const adapter = PROVIDER_ADAPTERS[providerType]; - - return adapter.formatErrorMessage(error); -}; +const dispatchErrorMessage = (providerType: ProviderType, error: unknown): string => + PROVIDER_ADAPTERS[providerType].formatErrorMessage(error); export { dispatchErrorMessage, dispatchSearch }; diff --git a/src/providers/moonshot/index.ts b/src/providers/moonshot/index.ts index 310e3b3..ebe76e3 100644 --- a/src/providers/moonshot/index.ts +++ b/src/providers/moonshot/index.ts @@ -6,7 +6,7 @@ import { } from "../shared/search.js"; import OpenAI, { APIError } from "openai"; -import { SearchArgs, SearchConfig } from "../../types.js"; +import { SearchConfig } from "../../types.js"; import { formatUnhandledSearchError } from "../shared/errors.js"; import { collectUniqueChatCompletionAnnotationHits, @@ -235,11 +235,11 @@ const runSearchLoop = async ( return buildMaxTurnsResponse(query); }; -const executeSearch = async (config: SearchConfig, args: SearchArgs): Promise => { +const executeSearch = async (config: SearchConfig, query: string): Promise => { const client = createOpenAICompatibleClient(config); - const messages = buildMessages(args.query); + const messages = buildMessages(query); - return runSearchLoop(client, config.model, messages, args.query); + return runSearchLoop(client, config.model, messages, query); }; export { executeSearch, formatErrorMessage }; diff --git a/src/providers/openai/index.ts b/src/providers/openai/index.ts index 188ce64..5e62b47 100644 --- a/src/providers/openai/index.ts +++ b/src/providers/openai/index.ts @@ -5,7 +5,7 @@ import { buildStructuredResponse, } from "../shared/search.js"; import OpenAI, { APIError } from "openai"; -import { SearchArgs, SearchConfig } from "../../types.js"; +import { SearchConfig } from "../../types.js"; import { collectUniqueAnnotationHits, createOpenAICompatibleClient, @@ -28,11 +28,11 @@ const formatErrorMessage = (error: unknown): string => { // ── Client and execution ─────────────────────────────────────────────── -const executeSearch = async (config: SearchConfig, args: SearchArgs): Promise => { +const executeSearch = async (config: SearchConfig, query: string): Promise => { const client = createOpenAICompatibleClient(config); const response = await client.responses.create({ - input: buildSearchInput(args.query), + input: buildSearchInput(query), instructions: SEARCH_SYSTEM_PROMPT, max_output_tokens: MAX_RESPONSE_TOKENS, model: config.model, @@ -40,7 +40,7 @@ const executeSearch = async (config: SearchConfig, args: SearchArgs): Promise = { + anthropic: "anthropic", + "github-copilot": "copilot", + moonshotai: "moonshot", + "moonshotai-cn": "moonshot", + openai: "openai", +}; +/** + * Provider types that are scannable from OpenCode provider config, + * in the order they should be preferred when multiple providers offer + * a `lockedModel` or `fallbackModel`. + */ +const SCANNABLE_TYPES: ScannableProviderType[] = ["anthropic", "copilot", "moonshot", "openai"]; + +/** + * Order in which we resolve `lockedModel` / `fallbackModel` candidates + * across providers when picking which one wins. + */ const RESOLUTION_PRIORITY: ProviderType[] = [ "anthropic", "chatgpt", @@ -30,83 +37,9 @@ const RESOLUTION_PRIORITY: ProviderType[] = [ "copilot", ]; -const PROVIDER_RULES: ProviderRule[] = [ - { - idHints: ["anthropic"], - npmPackage: "@ai-sdk/anthropic", - type: "anthropic", - }, - { - idHints: ["openai"], - npmPackage: "@ai-sdk/openai", - type: "openai", - }, - { - idHints: ["github-copilot", "copilot"], - npmPackage: "@ai-sdk/github-copilot", - type: "copilot", - }, -]; - // ── Helpers ──────────────────────────────────────────────────────────── -const isMoonshotProviderID = (providerID: string): boolean => { - const normalizedProviderID = providerID.toLowerCase(); - - return normalizedProviderID === MOONSHOT_PROVIDER_ID; -}; - -const detectProviderTypeFromNpm = (npmPackage: string): ProviderType | null => { - for (const rule of PROVIDER_RULES) { - if (rule.npmPackage === npmPackage) { - return rule.type; - } - } - - return null; -}; +const detectProviderType = (providerID: string): ScannableProviderType | null => + PROVIDER_TYPES_BY_ID[providerID] ?? null; -const detectProviderTypeFromProviderID = (providerID: string): ProviderType | null => { - if (isMoonshotProviderID(providerID)) { - return "moonshot"; - } - - const normalizedProviderID = providerID.toLowerCase(); - - for (const rule of PROVIDER_RULES) { - for (const hint of rule.idHints) { - if (normalizedProviderID.includes(hint)) { - return rule.type; - } - } - } - - return null; -}; - -const detectProviderTypeFromModel = ( - context: ProviderModelDetectionContext, -): ProviderType | null => { - const { model } = context; - const directType = detectProviderTypeFromNpm(model.api.npm); - if (directType) { - return directType; - } - - if (model.api.npm !== OPENAI_COMPATIBLE_PACKAGE) { - return null; - } - - if (isMoonshotProviderID(context.providerID)) { - return "moonshot"; - } - - return null; -}; - -export { - detectProviderTypeFromModel, - detectProviderTypeFromNpm, - detectProviderTypeFromProviderID, - RESOLUTION_PRIORITY, -}; +export { detectProviderType, RESOLUTION_PRIORITY, SCANNABLE_TYPES }; diff --git a/src/providers/shared/auth.ts b/src/providers/shared/auth.ts new file mode 100644 index 0000000..63e3434 --- /dev/null +++ b/src/providers/shared/auth.ts @@ -0,0 +1,82 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join, sep } from "node:path"; + +// ── Types ────────────────────────────────────────────────────────────── + +interface PathClient { + path: { + get: (options?: { query?: { directory?: string } }) => Promise<{ data?: { state?: string } }>; + }; +} + +// ── Constants ────────────────────────────────────────────────────────── + +const AUTH_FILE_NAME = "auth.json"; +const OPENCODE_DIR = "opencode"; +const PATH_START = 0; +const SHARE_DIR = "share"; +const STATE_DIR = "state"; + +// ── Helpers ──────────────────────────────────────────────────────────── + +const resolveAuthPathFromStatePath = (statePath: string | undefined): string | null => { + if (!statePath) { + return null; + } + + const stateMarker = `${sep}${STATE_DIR}${sep}${OPENCODE_DIR}`; + if (!statePath.endsWith(stateMarker)) { + return null; + } + + const dataMarker = `${sep}${SHARE_DIR}${sep}${OPENCODE_DIR}`; + const dataPath = `${statePath.slice(PATH_START, -stateMarker.length)}${dataMarker}`; + + return join(dataPath, AUTH_FILE_NAME); +}; + +const resolveAuthPath = async (client: PathClient, directory: string): Promise => { + const response = await client.path.get({ query: { directory } }); + + return resolveAuthPathFromStatePath(response.data?.state); +}; + +const parseAuthStore = (content: string): Record | null => { + try { + const parsed = JSON.parse(content) as unknown; + if (parsed && typeof parsed === "object") { + return parsed as Record; + } + return null; + } catch { + return null; + } +}; + +/** + * Read OpenCode's `auth.json` file and return the entry stored under `key`, + * or `null` if the file is missing, unparseable, or doesn't contain that key. + * + * The `Entry` type parameter describes the expected entry shape; the caller + * is responsible for validating the entry's fields. + */ +const readAuthEntry = async ( + client: PathClient, + directory: string, + key: string, +): Promise => { + const authPath = await resolveAuthPath(client, directory); + if (!authPath || !existsSync(authPath)) { + return null; + } + + const store = parseAuthStore(readFileSync(authPath, "utf8")); + const candidate = store?.[key]; + if (!candidate || typeof candidate !== "object") { + return null; + } + + return candidate as Entry; +}; + +export { PathClient, readAuthEntry }; diff --git a/src/types.ts b/src/types.ts index 7791f46..b54beda 100644 --- a/src/types.ts +++ b/src/types.ts @@ -15,6 +15,13 @@ interface ProviderCredentials { */ type ProviderType = "anthropic" | "chatgpt" | "copilot" | "moonshot" | "openai"; +/** + * Provider types that come from scanning OpenCode provider config. + * `chatgpt` is excluded because it is derived from OAuth credentials + * read separately from `auth.json`, not from a provider entry. + */ +type ScannableProviderType = Exclude; + /** * Fully resolved config for a single web search call: * credentials + the specific model to use. @@ -31,25 +38,17 @@ interface SearchConfig { * - `credentials`: API key + optional base URL * - `lockedModel`: model ID if a model has `websearch: "always"` (hard lock) * - `fallbackModel`: model ID if a model has `"websearch": "auto"` (soft fallback) - * - `providerType`: which provider this resolution belongs to */ interface ProviderResolution { credentials: ProviderCredentials; fallbackModel?: string; lockedModel?: string; - providerType: ProviderType; } /** * Map of provider resolutions, one per supported provider type. */ -interface ProviderResolutionMap { - anthropic?: ProviderResolution; - chatgpt?: ProviderResolution; - copilot?: ProviderResolution; - moonshot?: ProviderResolution; - openai?: ProviderResolution; -} +type ProviderResolutionMap = Partial>; // ── Active Model ─────────────────────────────────────────────────────── @@ -62,15 +61,6 @@ interface ActiveModel { providerID: string; } -// ── Tool Args ────────────────────────────────────────────────────────── - -/** - * Arguments accepted by the web-search tool. - */ -interface SearchArgs { - query: string; -} - // ── Structured Result Types ──────────────────────────────────────────── /** @@ -97,7 +87,7 @@ export { ProviderResolution, ProviderResolutionMap, ProviderType, - SearchArgs, + ScannableProviderType, SearchConfig, SearchHit, StructuredSearchResponse, From 59c8cbc90a68cf9b9638e602e269381f8a8e9c79 Mon Sep 17 00:00:00 2001 From: emilsvennesson Date: Wed, 29 Apr 2026 22:06:59 +0200 Subject: [PATCH 2/3] feat: support multiple providers of the same type Switch from a type-keyed resolution map to a list of resolutions keyed by OpenCode provider ID. This lets users have multiple providers of the same type (e.g. `openai-prod` and `openai-staging`) with their own credentials and baseURLs, all auto-detected via npm-package matching on `@ai-sdk/openai`, `@ai-sdk/anthropic`, or `@ai-sdk/github-copilot`. Detection now falls back to npm matching when the provider ID is not canonical. Active model routing matches by provider ID, so each renamed provider serves traffic on its own credentials. ChatGPT OAuth shadowing is scoped to the canonical `openai` provider with no baseURL; renamed openai-typed providers keep their explicit credentials. --- src/config.ts | 154 +++++++++++--------------------------- src/index.ts | 147 ++++++++++++++++-------------------- src/providers/registry.ts | 67 +++++++++++------ src/types.ts | 24 +++--- 4 files changed, 167 insertions(+), 225 deletions(-) diff --git a/src/config.ts b/src/config.ts index d9dc348..3a3cdfc 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,10 +1,5 @@ -import { - ProviderCredentials, - ProviderResolution, - ProviderResolutionMap, - ScannableProviderType, -} from "./types.js"; -import { SCANNABLE_TYPES, detectProviderType } from "./providers/registry.js"; +import { ProviderResolution, ScannableProviderType } from "./types.js"; +import { detectProviderType } from "./providers/registry.js"; // ── Types ────────────────────────────────────────────────────────────── @@ -17,20 +12,12 @@ interface ProviderData { interface ProviderModel { api: { - url?: string; + npm: string; }; id: string; options: Record; } -interface ScanResult { - credentials: ProviderCredentials | null; - fallbackModel?: string; - lockedModel?: string; -} - -type ScanState = Record; - // ── Constants ────────────────────────────────────────────────────────── const WEBSEARCH_ALWAYS = "always"; @@ -49,127 +36,77 @@ const getWebsearchOption = (model: ProviderModel): string | null => { const stripV1Suffix = (url: string): string => url.replace(/\/v1\/?$/, ""); -const extractApiKey = (options: Record): string | undefined => - typeof options.apiKey === "string" ? options.apiKey : undefined; - const extractStringOption = (options: Record, key: string): string | undefined => typeof options[key] === "string" ? (options[key] as string) : undefined; -const normalizeBaseURL = (providerType: ScannableProviderType, baseURL: string): string => - providerType === "anthropic" ? stripV1Suffix(baseURL) : baseURL; +const normalizeBaseURL = (type: ScannableProviderType, baseURL: string): string => + type === "anthropic" ? stripV1Suffix(baseURL) : baseURL; const resolveBaseURL = ( provider: ProviderData, - providerType: ScannableProviderType, - model: ProviderModel, + type: ScannableProviderType, ): string | undefined => { const configured = extractStringOption(provider.options, "baseURL"); - if (configured) { - return normalizeBaseURL(providerType, configured); - } - const modelURL = model.api.url; - if (typeof modelURL !== "string") { - return undefined; - } - - return normalizeBaseURL(providerType, modelURL); + return configured ? normalizeBaseURL(type, configured) : undefined; }; -const extractCredentials = ( +const collectWebsearchModels = ( provider: ProviderData, - providerType: ScannableProviderType, - model: ProviderModel, -): ProviderCredentials | null => { - const apiKey = provider.key ?? extractApiKey(provider.options); - if (!apiKey) { - return null; - } +): { fallbackModel?: string; lockedModel?: string } => { + let lockedModel: string | undefined = undefined; + let fallbackModel: string | undefined = undefined; - return { apiKey, baseURL: resolveBaseURL(provider, providerType, model) }; -}; - -const createInitialScanState = (): ScanState => - Object.fromEntries(SCANNABLE_TYPES.map((type) => [type, { credentials: null }])) as ScanState; - -const updateModelsFromWebsearchOption = (scan: ScanResult, model: ProviderModel): void => { - const option = getWebsearchOption(model); - if (option === WEBSEARCH_ALWAYS && !scan.lockedModel) { - scan.lockedModel = model.id; - } - - if (option === WEBSEARCH_AUTO && !scan.fallbackModel) { - scan.fallbackModel = model.id; - } -}; - -const processProviderModel = ( - provider: ProviderData, - model: ProviderModel, - providerType: ScannableProviderType, - state: ScanState, -): void => { - const scan = state[providerType]; - const candidate = extractCredentials(provider, providerType, model); - if (!scan.credentials) { - scan.credentials = candidate; - } else if (!scan.credentials.baseURL && candidate?.baseURL) { - scan.credentials = { ...scan.credentials, baseURL: candidate.baseURL }; + for (const model of Object.values(provider.models)) { + const flag = getWebsearchOption(model); + if (flag === WEBSEARCH_ALWAYS && !lockedModel) { + lockedModel = model.id; + } + if (flag === WEBSEARCH_AUTO && !fallbackModel) { + fallbackModel = model.id; + } } - updateModelsFromWebsearchOption(scan, model); + return { fallbackModel, lockedModel }; }; -const scanProvider = (provider: ProviderData, state: ScanState): void => { - const providerType = detectProviderType(provider.id); - if (!providerType) { - return; - } - - for (const model of Object.values(provider.models)) { - processProviderModel(provider, model, providerType, state); +const scanProvider = (provider: ProviderData): ProviderResolution | null => { + const type = detectProviderType(provider); + if (!type) { + return null; } -}; -const buildResolution = (scan: ScanResult): ProviderResolution | null => { - if (!scan.credentials) { + const apiKey = provider.key ?? extractStringOption(provider.options, "apiKey"); + if (!apiKey) { return null; } + const { fallbackModel, lockedModel } = collectWebsearchModels(provider); + return { - credentials: scan.credentials, - fallbackModel: scan.fallbackModel, - lockedModel: scan.lockedModel, + credentials: { apiKey, baseURL: resolveBaseURL(provider, type) }, + fallbackModel, + lockedModel, + providerID: provider.id, + type, }; }; // ── Public API ───────────────────────────────────────────────────────── /** - * Scan all providers in one pass, recording credentials and any - * `websearch`-tagged models per scannable provider type. + * Scan all providers, emitting one resolution per detected provider. + * Resolutions preserve OpenCode's config insertion order, which is the + * order used to break ties when multiple providers expose `websearch` + * flags. */ -const scanProviders = (providers: ProviderData[]): ScanState => { - const state = createInitialScanState(); +const scanProviders = (providers: ProviderData[]): ProviderResolution[] => { + const result: ProviderResolution[] = []; for (const provider of providers) { - scanProvider(provider, state); - } - - return state; -}; - -/** - * Build the resolution map from a previously computed scan state. - * Only providers with credentials are emitted. - */ -const buildResolutionMap = (state: ScanState): ProviderResolutionMap => { - const result: ProviderResolutionMap = {}; - - for (const type of SCANNABLE_TYPES) { - const resolution = buildResolution(state[type]); + const resolution = scanProvider(provider); if (resolution) { - result[type] = resolution; + result.push(resolution); } } @@ -254,11 +191,4 @@ You can either: Or set \`"websearch": "always"\` to always use that model for web search regardless of your active model.`; -export { - buildResolutionMap, - formatNoProviderError, - formatUnsupportedProviderError, - ProviderData, - ScanState, - scanProviders, -}; +export { formatNoProviderError, formatUnsupportedProviderError, ProviderData, scanProviders }; diff --git a/src/index.ts b/src/index.ts index 7b6e131..dc5d8b9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,13 +1,11 @@ -import { ActiveModel, ProviderResolution, ProviderResolutionMap, ProviderType } from "./types.js"; +import { ActiveModel, ProviderResolution } from "./types.js"; import { Plugin, PluginInput, tool } from "@opencode-ai/plugin"; import { ProviderData, - buildResolutionMap, formatNoProviderError, formatUnsupportedProviderError, scanProviders, } from "./config.js"; -import { RESOLUTION_PRIORITY, detectProviderType } from "./providers/registry.js"; import { dispatchErrorMessage, dispatchSearch } from "./providers/index.js"; import { getCurrentMonthYear } from "./helpers.js"; import { resolveChatGPTCredentials } from "./providers/chatgpt/auth.js"; @@ -15,84 +13,63 @@ import { resolveCopilotCredentials } from "./providers/copilot/auth.js"; // ── Constants ────────────────────────────────────────────────────────── +const CANONICAL_COPILOT_ID = "github-copilot"; +const CANONICAL_OPENAI_ID = "openai"; const MIN_QUERY_LENGTH = 2; -const NO_PROVIDERS = 0; +const NO_RESOLUTIONS = 0; // ── Types ────────────────────────────────────────────────────────────── interface PickedModel { modelID: string; resolution: ProviderResolution; - type: ProviderType; } -// ── Provider detection ───────────────────────────────────────────────── +// ── Lookup ───────────────────────────────────────────────────────────── -/** - * Resolve which adapter type to use for the active model. The user is on - * the canonical OpenCode provider (e.g. `openai`), but if ChatGPT OAuth - * is present we route OpenAI traffic through the ChatGPT adapter. - */ -const resolveActiveType = ( - active: ActiveModel | undefined, - resolutions: ProviderResolutionMap, -): ProviderType | null => { - if (!active) { - return null; - } - - const base = detectProviderType(active.providerID); +const findActive = ( + active: ActiveModel, + resolutions: ProviderResolution[], +): ProviderResolution | null => + resolutions.find((resolution) => resolution.providerID === active.providerID) ?? null; - return base === "openai" && resolutions.chatgpt ? "chatgpt" : base; -}; - -// ── Model resolution ─────────────────────────────────────────────────── - -/** - * Walk providers in priority order looking for one that has the given - * model key (`lockedModel` or `fallbackModel`) set. - */ -const findModelByKey = ( - resolutions: ProviderResolutionMap, +const findFirstWithKey = ( + resolutions: ProviderResolution[], key: "fallbackModel" | "lockedModel", -): PickedModel | null => { - for (const type of RESOLUTION_PRIORITY) { - const resolution = resolutions[type]; - const modelID = resolution?.[key]; - if (resolution && modelID) { - return { modelID, resolution, type }; - } - } - - return null; -}; +): ProviderResolution | null => resolutions.find((resolution) => Boolean(resolution[key])) ?? null; /** - * Pick the model and provider to use for a web search call. + * Pick the (model, resolution) pair to use for a web search call. * * Priority: * 1. Locked model (`"websearch": "always"`) on any provider - * 2. Active model if its provider is supported + * 2. Active model if its provider is in the resolution list * 3. Fallback model (`"websearch": "auto"`) on any provider + * + * Within a tier, providers are walked in OpenCode config insertion order. */ const pickModel = ( - resolutions: ProviderResolutionMap, + resolutions: ProviderResolution[], active: ActiveModel | undefined, - activeType: ProviderType | null, ): PickedModel | null => { - const locked = findModelByKey(resolutions, "lockedModel"); - if (locked) { - return locked; + const locked = findFirstWithKey(resolutions, "lockedModel"); + if (locked?.lockedModel) { + return { modelID: locked.lockedModel, resolution: locked }; } - if (active && activeType) { - const resolution = resolutions[activeType]; - if (resolution) { - return { modelID: active.modelID, resolution, type: activeType }; + if (active) { + const direct = findActive(active, resolutions); + if (direct) { + return { modelID: active.modelID, resolution: direct }; } } - return findModelByKey(resolutions, "fallbackModel"); + const fallback = findFirstWithKey(resolutions, "fallbackModel"); + if (fallback?.fallbackModel) { + return { modelID: fallback.fallbackModel, resolution: fallback }; + } + + return null; }; // ── Resolution loading ───────────────────────────────────────────────── @@ -100,41 +77,50 @@ const pickModel = ( const loadResolutions = async ( client: PluginInput["client"], directory: string, -): Promise => { +): Promise => { const { data } = await client.config.providers(); if (!data) { - return {}; + return []; } - const list = data.providers as ProviderData[]; - const scan = scanProviders(list); - const resolutions = buildResolutionMap(scan); + const resolutions = scanProviders(data.providers as ProviderData[]); /* - * ChatGPT OAuth supersedes the OpenAI provider unless the user has explicitly - * pointed `openai` at a custom baseURL (e.g. a proxy). + * ChatGPT OAuth shadows the canonical `openai` provider when no custom + * baseURL is set. Custom-renamed openai-typed providers keep their own + * credentials because their explicit baseURL would not authenticate + * against ChatGPT OAuth tokens. */ const chatgpt = await resolveChatGPTCredentials(client, directory); - const openaiBaseURL = resolutions.openai?.credentials.baseURL?.trim(); - if (chatgpt && !openaiBaseURL) { - resolutions.chatgpt = { - credentials: chatgpt, - fallbackModel: scan.openai.fallbackModel, - lockedModel: scan.openai.lockedModel, - }; + if (chatgpt) { + const canonical = resolutions.find( + (resolution) => resolution.providerID === CANONICAL_OPENAI_ID, + ); + if (canonical && !canonical.credentials.baseURL) { + canonical.credentials = chatgpt; + canonical.type = "chatgpt"; + } } /* - * Copilot credentials live in OpenCode's auth.json, not in opencode.json, - * but websearch flags can still be set on the `github-copilot` provider. + * Copilot OAuth replaces credentials on the canonical `github-copilot` + * resolution if present, otherwise appends a synthetic resolution so + * the user can still web-search via Copilot without a config entry. */ const copilot = await resolveCopilotCredentials(client, directory); if (copilot) { - resolutions.copilot = { - credentials: copilot, - fallbackModel: scan.copilot.fallbackModel, - lockedModel: scan.copilot.lockedModel, - }; + const canonical = resolutions.find( + (resolution) => resolution.providerID === CANONICAL_COPILOT_ID, + ); + if (canonical) { + canonical.credentials = copilot; + } else { + resolutions.push({ + credentials: copilot, + providerID: CANONICAL_COPILOT_ID, + type: "copilot", + }); + } } return resolutions; @@ -144,7 +130,7 @@ const loadResolutions = async ( // oxlint-disable-next-line import/no-default-export -- plugin entry point requires default export export default (async (input) => { - let resolutions: ProviderResolutionMap | null = null; + let resolutions: ProviderResolution[] | null = null; const activeModels = new Map(); return { @@ -189,23 +175,22 @@ IMPORTANT - Use the correct year in search queries: resolutions ??= await loadResolutions(input.client, input.directory); const active = activeModels.get(context.sessionID); - const activeType = resolveActiveType(active, resolutions); - const picked = pickModel(resolutions, active, activeType); + const picked = pickModel(resolutions, active); if (!picked) { - return Object.keys(resolutions).length === NO_PROVIDERS + return resolutions.length === NO_RESOLUTIONS ? formatNoProviderError() : formatUnsupportedProviderError(active?.modelID ?? "unknown"); } try { return await dispatchSearch( - picked.type, + picked.resolution.type, { ...picked.resolution.credentials, model: picked.modelID }, args.query, ); } catch (error) { - return dispatchErrorMessage(picked.type, error); + return dispatchErrorMessage(picked.resolution.type, error); } }, }), diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 5921a33..9bdc883 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -1,4 +1,15 @@ -import { ProviderType, ScannableProviderType } from "../types.js"; +import { ScannableProviderType } from "../types.js"; + +// ── Types ────────────────────────────────────────────────────────────── + +interface ProviderModelLike { + api: { npm: string }; +} + +interface ProviderDataLike { + id: string; + models: Record; +} // ── Constants ────────────────────────────────────────────────────────── @@ -6,9 +17,6 @@ import { ProviderType, ScannableProviderType } from "../types.js"; * Map of canonical OpenCode provider IDs to the web-search provider type * that handles them. The IDs match the well-known identifiers used by * OpenCode and models.dev (see `packages/opencode/src/provider/schema.ts`). - * - * Custom-renamed providers in `opencode.json` are intentionally not - * supported here — users must use the canonical provider ID. */ const PROVIDER_TYPES_BY_ID: Record = { anthropic: "anthropic", @@ -19,27 +27,44 @@ const PROVIDER_TYPES_BY_ID: Record = { }; /** - * Provider types that are scannable from OpenCode provider config, - * in the order they should be preferred when multiple providers offer - * a `lockedModel` or `fallbackModel`. + * Unambiguous SDK-package-to-type mappings. Used to detect custom-renamed + * providers (e.g. `openai-prod`, `openai-staging`) so they can be handled + * as their underlying type with their own credentials and baseURL. + * + * Moonshot is intentionally omitted: `@ai-sdk/openai-compatible` is shared + * by many unrelated providers and cannot be auto-detected by npm alone. */ -const SCANNABLE_TYPES: ScannableProviderType[] = ["anthropic", "copilot", "moonshot", "openai"]; +const NPM_TO_TYPE: Record = { + "@ai-sdk/anthropic": "anthropic", + "@ai-sdk/github-copilot": "copilot", + "@ai-sdk/openai": "openai", +}; + +// ── Helpers ──────────────────────────────────────────────────────────── /** - * Order in which we resolve `lockedModel` / `fallbackModel` candidates - * across providers when picking which one wins. + * Identify which adapter type should serve a given provider. + * + * 1. Match the canonical OpenCode provider ID (covers the common case). + * 2. Fall back to matching any model's `api.npm` against the unambiguous + * SDK packages (covers custom-renamed providers like `openai-prod`). + * + * Returns `null` if neither matches; the provider will be ignored. */ -const RESOLUTION_PRIORITY: ProviderType[] = [ - "anthropic", - "chatgpt", - "moonshot", - "openai", - "copilot", -]; +const detectProviderType = (provider: ProviderDataLike): ScannableProviderType | null => { + const byID = PROVIDER_TYPES_BY_ID[provider.id]; + if (byID) { + return byID; + } -// ── Helpers ──────────────────────────────────────────────────────────── + for (const model of Object.values(provider.models)) { + const byNpm = NPM_TO_TYPE[model.api.npm]; + if (byNpm) { + return byNpm; + } + } -const detectProviderType = (providerID: string): ScannableProviderType | null => - PROVIDER_TYPES_BY_ID[providerID] ?? null; + return null; +}; -export { detectProviderType, RESOLUTION_PRIORITY, SCANNABLE_TYPES }; +export { detectProviderType }; diff --git a/src/types.ts b/src/types.ts index b54beda..258661d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -16,7 +16,7 @@ interface ProviderCredentials { type ProviderType = "anthropic" | "chatgpt" | "copilot" | "moonshot" | "openai"; /** - * Provider types that come from scanning OpenCode provider config. + * Provider types detectable from OpenCode provider config. * `chatgpt` is excluded because it is derived from OAuth credentials * read separately from `auth.json`, not from a provider entry. */ @@ -34,22 +34,25 @@ interface SearchConfig { } /** - * The result of scanning a single provider at startup: - * - `credentials`: API key + optional base URL - * - `lockedModel`: model ID if a model has `websearch: "always"` (hard lock) - * - `fallbackModel`: model ID if a model has `"websearch": "auto"` (soft fallback) + * One scanned provider, ready to answer a web-search call. + * + * - `providerID`: the OpenCode provider ID (e.g. `"openai"`, + * `"openai-prod"`). Resolutions are matched against the active model's + * `providerID` directly, allowing multiple providers of the same type + * with different credentials/baseURLs to coexist. + * - `type`: which adapter to dispatch to. May be mutated after scanning + * when ChatGPT OAuth shadows the canonical `openai` provider. + * - `lockedModel`: model ID if a model has `"websearch": "always"`. + * - `fallbackModel`: model ID if a model has `"websearch": "auto"`. */ interface ProviderResolution { credentials: ProviderCredentials; fallbackModel?: string; lockedModel?: string; + providerID: string; + type: ProviderType; } -/** - * Map of provider resolutions, one per supported provider type. - */ -type ProviderResolutionMap = Partial>; - // ── Active Model ─────────────────────────────────────────────────────── /** @@ -85,7 +88,6 @@ export { ActiveModel, ProviderCredentials, ProviderResolution, - ProviderResolutionMap, ProviderType, ScannableProviderType, SearchConfig, From 81dc92fa9d45623554cc659f185059013eba32c3 Mon Sep 17 00:00:00 2001 From: emilsvennesson Date: Wed, 29 Apr 2026 22:27:50 +0200 Subject: [PATCH 3/3] fix: honor websearch flags on auth-only providers and address review feedback Allow scanProvider to emit credential-less scan results when a provider exposes websearch flags but no apiKey, so an OAuth-only github-copilot config that pins a model via `"websearch": "always"` is no longer silently dropped. The OAuth attachment phase fills in credentials with a unified baseURL guard that also preserves any explicit Copilot proxy configuration. Use the canonical `moonshotai` provider ID in the formatNoProviderError example block. --- src/config.ts | 58 +++++++++++++++++++++++++++++++++++++++------------ src/index.ts | 45 +++++++++++++++++++++++---------------- 2 files changed, 72 insertions(+), 31 deletions(-) diff --git a/src/config.ts b/src/config.ts index 3a3cdfc..6483ba0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,4 +1,4 @@ -import { ProviderResolution, ScannableProviderType } from "./types.js"; +import { ProviderCredentials, ProviderType, ScannableProviderType } from "./types.js"; import { detectProviderType } from "./providers/registry.js"; // ── Types ────────────────────────────────────────────────────────────── @@ -18,6 +18,26 @@ interface ProviderModel { options: Record; } +/** + * A scan result that may not yet have credentials. + * + * Distinct from `ProviderResolution` because we want to preserve the + * `websearch` flags configured on a provider even when its API key is + * missing — the OAuth attachment step in `loadResolutions` can fill + * those credentials in afterwards (e.g. for `github-copilot` configured + * only with `"websearch": "always"` flags and authenticated via OAuth). + * + * Resolutions still missing credentials after OAuth attachment are + * filtered out before the public `ProviderResolution[]` is returned. + */ +interface ScannedResolution { + credentials: ProviderCredentials | null; + fallbackModel?: string; + lockedModel?: string; + providerID: string; + type: ProviderType; +} + // ── Constants ────────────────────────────────────────────────────────── const WEBSEARCH_ALWAYS = "always"; @@ -70,21 +90,24 @@ const collectWebsearchModels = ( return { fallbackModel, lockedModel }; }; -const scanProvider = (provider: ProviderData): ProviderResolution | null => { +const scanProvider = (provider: ProviderData): ScannedResolution | null => { const type = detectProviderType(provider); if (!type) { return null; } const apiKey = provider.key ?? extractStringOption(provider.options, "apiKey"); - if (!apiKey) { + const { fallbackModel, lockedModel } = collectWebsearchModels(provider); + + // Skip providers that contribute nothing: no credentials and no flags. + if (!apiKey && !lockedModel && !fallbackModel) { return null; } - const { fallbackModel, lockedModel } = collectWebsearchModels(provider); + const credentials = apiKey ? { apiKey, baseURL: resolveBaseURL(provider, type) } : null; return { - credentials: { apiKey, baseURL: resolveBaseURL(provider, type) }, + credentials, fallbackModel, lockedModel, providerID: provider.id, @@ -95,13 +118,16 @@ const scanProvider = (provider: ProviderData): ProviderResolution | null => { // ── Public API ───────────────────────────────────────────────────────── /** - * Scan all providers, emitting one resolution per detected provider. - * Resolutions preserve OpenCode's config insertion order, which is the - * order used to break ties when multiple providers expose `websearch` - * flags. + * Scan all providers, emitting one scan result per detected provider. + * + * Each result preserves OpenCode's config insertion order, which is + * the order used to break ties when multiple providers expose + * `websearch` flags. Some entries may have null credentials at this + * stage; the OAuth attachment phase fills them in (and any still-null + * entries are filtered out before being returned to callers). */ -const scanProviders = (providers: ProviderData[]): ProviderResolution[] => { - const result: ProviderResolution[] = []; +const scanProviders = (providers: ProviderData[]): ScannedResolution[] => { + const result: ScannedResolution[] = []; for (const provider of providers) { const resolution = scanProvider(provider); @@ -151,7 +177,7 @@ Or: { "provider": { - "moonshot": { + "moonshotai": { "options": { "apiKey": "{env:MOONSHOT_API_KEY}" } @@ -191,4 +217,10 @@ You can either: Or set \`"websearch": "always"\` to always use that model for web search regardless of your active model.`; -export { formatNoProviderError, formatUnsupportedProviderError, ProviderData, scanProviders }; +export { + formatNoProviderError, + formatUnsupportedProviderError, + ProviderData, + ScannedResolution, + scanProviders, +}; diff --git a/src/index.ts b/src/index.ts index dc5d8b9..251facb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ import { ActiveModel, ProviderResolution } from "./types.js"; import { Plugin, PluginInput, tool } from "@opencode-ai/plugin"; import { ProviderData, + ScannedResolution, formatNoProviderError, formatUnsupportedProviderError, scanProviders, @@ -74,6 +75,9 @@ const pickModel = ( // ── Resolution loading ───────────────────────────────────────────────── +const hasCredentials = (resolution: ScannedResolution): resolution is ProviderResolution => + resolution.credentials !== null; + const loadResolutions = async ( client: PluginInput["client"], directory: string, @@ -83,39 +87,44 @@ const loadResolutions = async ( return []; } - const resolutions = scanProviders(data.providers as ProviderData[]); + const scanned = scanProviders(data.providers as ProviderData[]); /* - * ChatGPT OAuth shadows the canonical `openai` provider when no custom - * baseURL is set. Custom-renamed openai-typed providers keep their own - * credentials because their explicit baseURL would not authenticate + * ChatGPT OAuth shadows the canonical `openai` provider when it has no + * explicit baseURL. This covers two cases: + * - a configured `openai` provider with apiKey but no baseURL: OAuth + * replaces the apiKey and the type flips to `"chatgpt"`. + * - a credential-less `openai` block configured only for `websearch` + * flags: OAuth fills in the credentials, preserving those flags. + * + * Custom-renamed openai-typed providers (e.g. `openai-prod`) keep their + * own credentials because their explicit baseURL would not authenticate * against ChatGPT OAuth tokens. */ const chatgpt = await resolveChatGPTCredentials(client, directory); if (chatgpt) { - const canonical = resolutions.find( - (resolution) => resolution.providerID === CANONICAL_OPENAI_ID, - ); - if (canonical && !canonical.credentials.baseURL) { + const canonical = scanned.find((resolution) => resolution.providerID === CANONICAL_OPENAI_ID); + if (canonical && !canonical.credentials?.baseURL) { canonical.credentials = chatgpt; canonical.type = "chatgpt"; } } /* - * Copilot OAuth replaces credentials on the canonical `github-copilot` - * resolution if present, otherwise appends a synthetic resolution so - * the user can still web-search via Copilot without a config entry. + * Copilot OAuth credentials fill in the canonical `github-copilot` + * resolution when it has no explicit baseURL — a user with a custom + * Copilot proxy keeps their config. Same optional-chain guard handles + * the credential-less `"websearch"`-flags-only case. If no canonical + * resolution exists at all, we synthesize one so OAuth-only users can + * still web-search via Copilot without an opencode.json entry. */ const copilot = await resolveCopilotCredentials(client, directory); if (copilot) { - const canonical = resolutions.find( - (resolution) => resolution.providerID === CANONICAL_COPILOT_ID, - ); - if (canonical) { + const canonical = scanned.find((resolution) => resolution.providerID === CANONICAL_COPILOT_ID); + if (canonical && !canonical.credentials?.baseURL) { canonical.credentials = copilot; - } else { - resolutions.push({ + } else if (!canonical) { + scanned.push({ credentials: copilot, providerID: CANONICAL_COPILOT_ID, type: "copilot", @@ -123,7 +132,7 @@ const loadResolutions = async ( } } - return resolutions; + return scanned.filter(hasCredentials); }; // ── Plugin ─────────────────────────────────────────────────────────────