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..6483ba0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,10 +1,5 @@ -import { - ProviderCredentials, - ProviderResolution, - ProviderResolutionMap, - ProviderType, -} from "./types.js"; -import { detectProviderTypeFromModel } from "./providers/registry.js"; +import { ProviderCredentials, ProviderType, ScannableProviderType } from "./types.js"; +import { detectProviderType } from "./providers/registry.js"; // ── Types ────────────────────────────────────────────────────────────── @@ -18,32 +13,31 @@ interface ProviderData { interface ProviderModel { api: { npm: string; - url?: string; }; id: string; options: Record; } -interface ProviderModelOverrides { - fallbackModel?: string; - lockedModel?: string; -} - -interface ScanResult { +/** + * 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; } -interface ScanState { - anthropic: ScanResult; - copilot: ScanResult; - moonshot: ScanResult; - openai: ScanResult; -} - -type ScannableProviderType = Exclude; - // ── Constants ────────────────────────────────────────────────────────── const WEBSEARCH_ALWAYS = "always"; @@ -60,229 +54,95 @@ const getWebsearchOption = (model: ProviderModel): string | null => { return null; }; -const normalizeBaseURL = (url: string): string => url.replace(/\/v1\/?$/, ""); +const stripV1Suffix = (url: string): string => url.replace(/\/v1\/?$/, ""); -const extractApiKey = (options: Record): string | undefined => { - if (typeof options.apiKey !== "string") { - return undefined; - } - - return options.apiKey; -}; +const extractStringOption = (options: Record, key: string): string | undefined => + typeof options[key] === "string" ? (options[key] as string) : undefined; -const extractConfiguredBaseURL = (options: Record): string | undefined => { - if (typeof options.baseURL !== "string") { - return undefined; - } - - return options.baseURL; -}; - -const normalizeProviderBaseURL = (providerType: ProviderType, baseURL: string): string => { - if (providerType === "anthropic") { - return normalizeBaseURL(baseURL); - } - - return baseURL; -}; - -const extractModelBaseURL = (model: ProviderModel): string | undefined => { - if (typeof model.api.url !== "string") { - return undefined; - } - - return model.api.url; -}; +const normalizeBaseURL = (type: ScannableProviderType, baseURL: string): string => + type === "anthropic" ? stripV1Suffix(baseURL) : baseURL; const resolveBaseURL = ( provider: ProviderData, - providerType: ProviderType, - model: ProviderModel, + type: ScannableProviderType, ): string | undefined => { - const configuredBaseURL = extractConfiguredBaseURL(provider.options); - if (configuredBaseURL) { - return normalizeProviderBaseURL(providerType, configuredBaseURL); - } - - const modelBaseURL = extractModelBaseURL(model); - if (!modelBaseURL) { - return undefined; - } + const configured = extractStringOption(provider.options, "baseURL"); - if (providerType === "anthropic") { - return normalizeBaseURL(modelBaseURL); - } - - return modelBaseURL; + return configured ? normalizeBaseURL(type, configured) : undefined; }; -const extractCredentials = ( +const collectWebsearchModels = ( provider: ProviderData, - providerType: ProviderType, - model: ProviderModel, -): ProviderCredentials | null => { - const apiKey = provider.key ?? extractApiKey(provider.options); - if (!apiKey) { - return null; - } - - 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 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; - } -}; +): { fallbackModel?: string; lockedModel?: string } => { + let lockedModel: string | undefined = undefined; + let fallbackModel: string | undefined = undefined; -const processProviderModel = ( - provider: ProviderData, - model: ProviderModel, - 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) { - scan.credentials = candidate; - } else if (!scan.credentials.baseURL && candidate?.baseURL) { - scan.credentials = { ...scan.credentials, baseURL: candidate.baseURL }; - } - - updateModelsFromWebsearchOption(scan, model); -}; - -const scanProvider = (provider: ProviderData, state: ScanState): void => { for (const model of Object.values(provider.models)) { - processProviderModel(provider, model, state); - } -}; - -const scanProviders = (providers: ProviderData[]): ScanState => { - const state = createInitialScanState(); - - for (const provider of providers) { - scanProvider(provider, state); + const flag = getWebsearchOption(model); + if (flag === WEBSEARCH_ALWAYS && !lockedModel) { + lockedModel = model.id; + } + if (flag === WEBSEARCH_AUTO && !fallbackModel) { + fallbackModel = model.id; + } } - return state; + return { fallbackModel, lockedModel }; }; -const buildResolution = ( - scan: ScanResult, - providerType: ScannableProviderType, -): ProviderResolution | null => { - if (!scan.credentials) { +const scanProvider = (provider: ProviderData): ScannedResolution | null => { + const type = detectProviderType(provider); + if (!type) { return null; } - return { - 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; - } + const apiKey = provider.key ?? extractStringOption(provider.options, "apiKey"); + const { fallbackModel, lockedModel } = collectWebsearchModels(provider); - const moonshot = buildResolution(state.moonshot, "moonshot"); - if (moonshot) { - result.moonshot = moonshot; + // Skip providers that contribute nothing: no credentials and no flags. + if (!apiKey && !lockedModel && !fallbackModel) { + return null; } - const copilot = buildResolution(state.copilot, "copilot"); - if (copilot) { - result.copilot = copilot; - } + const credentials = apiKey ? { apiKey, baseURL: resolveBaseURL(provider, type) } : null; - return result; + return { + credentials, + fallbackModel, + lockedModel, + providerID: provider.id, + type, + }; }; -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]); -}; +// ── Public API ───────────────────────────────────────────────────────── /** - * Scan providers for Anthropic, OpenAI, Moonshot, and GitHub Copilot credentials - * and any websearch-tagged models. + * Scan all providers, emitting one scan result per detected provider. * - * 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. + * 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 resolveFromProviders = (providers: ProviderData[]): ProviderResolutionMap => { - const state = scanProviders(providers); +const scanProviders = (providers: ProviderData[]): ScannedResolution[] => { + const result: ScannedResolution[] = []; + + for (const provider of providers) { + const resolution = scanProvider(provider); + if (resolution) { + result.push(resolution); + } + } - return buildResolutionMap(state); + 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. @@ -317,7 +177,7 @@ Or: { "provider": { - "moonshot": { + "moonshotai": { "options": { "apiKey": "{env:MOONSHOT_API_KEY}" } @@ -335,10 +195,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: { @@ -361,6 +221,6 @@ export { formatNoProviderError, formatUnsupportedProviderError, ProviderData, - resolveModelOverrides, - resolveFromProviders, + ScannedResolution, + scanProviders, }; diff --git a/src/index.ts b/src/index.ts index e6a2bc8..251facb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,352 +1,145 @@ -import { - ActiveModel, - ProviderResolution, - ProviderResolutionMap, - ProviderType, - SearchConfig, -} from "./types.js"; -import { Plugin, tool } from "@opencode-ai/plugin"; +import { ActiveModel, ProviderResolution } from "./types.js"; +import { Plugin, PluginInput, tool } from "@opencode-ai/plugin"; import { ProviderData, + ScannedResolution, formatNoProviderError, formatUnsupportedProviderError, - resolveFromProviders, - resolveModelOverrides, + scanProviders, } from "./config.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 CANONICAL_COPILOT_ID = "github-copilot"; +const CANONICAL_OPENAI_ID = "openai"; const MIN_QUERY_LENGTH = 2; +const NO_RESOLUTIONS = 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; -}; - -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; - } +// ── Types ────────────────────────────────────────────────────────────── - const modelType = detectTypeFromProviderModel(provider, model); - if (modelType) { - return modelType; - } - } - } - - return null; -}; - -const detectActiveProviderType = ( - active: ActiveModel | undefined, - providers: ProviderData[], -): 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; - } - - return detectProviderTypeFromProviderID(active.providerID); -}; - -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; +interface PickedModel { + modelID: string; + resolution: ProviderResolution; } -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; -}; +// ── Lookup ───────────────────────────────────────────────────────────── -/** - * Resolve the locked model for a given provider resolution. - * Returns a ResolvedProvider if a locked model is set, otherwise null. - */ -const resolveLockedModel = (resolutions: ProviderResolutionMap): ResolvedProvider | null => - resolveModelByPriority(resolutions, "lockedModel"); - -/** - * Resolve using the active model's provider directly. - */ -const resolveActiveModel = ( - activeType: ProviderType, +const findActive = ( active: ActiveModel, - resolutions: ProviderResolutionMap, -): ResolvedProvider | null => { - if (activeType === "openai" && resolutions.chatgpt) { - return { - config: buildSearchConfig(resolutions.chatgpt, active.modelID), - providerType: "chatgpt", - }; - } + resolutions: ProviderResolution[], +): ProviderResolution | null => + resolutions.find((resolution) => resolution.providerID === active.providerID) ?? null; - const resolution = resolutions[activeType]; - if (!resolution) { - return null; - } - - return { - config: buildSearchConfig(resolution, active.modelID), - providerType: activeType, - }; -}; +const findFirstWithKey = ( + resolutions: ProviderResolution[], + key: "fallbackModel" | "lockedModel", +): ProviderResolution | null => resolutions.find((resolution) => Boolean(resolution[key])) ?? 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, resolution) pair 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 in the resolution list + * 3. Fallback model (`"websearch": "auto"`) on any provider + * + * Within a tier, providers are walked in OpenCode config insertion order. */ -const resolveSearchProvider = ( - resolutions: ProviderResolutionMap, +const pickModel = ( + resolutions: ProviderResolution[], active: ActiveModel | undefined, - activeType: ProviderType | null, -): ResolvedProvider | null => { - const locked = resolveLockedModel(resolutions); - if (locked) { - return locked; +): PickedModel | null => { + const locked = findFirstWithKey(resolutions, "lockedModel"); + if (locked?.lockedModel) { + return { modelID: locked.lockedModel, resolution: locked }; } - if (activeType && active) { - const resolved = resolveActiveModel(activeType, active, resolutions); - if (resolved) { - return resolved; + if (active) { + const direct = findActive(active, resolutions); + if (direct) { + return { modelID: active.modelID, resolution: direct }; } } - return resolveFallbackModel(resolutions); + const fallback = findFirstWithKey(resolutions, "fallbackModel"); + if (fallback?.fallbackModel) { + return { modelID: fallback.fallbackModel, resolution: fallback }; + } + + return null; }; -// ── Lazy provider resolution ─────────────────────────────────────────── +// ── Resolution loading ───────────────────────────────────────────────── -interface ProviderState { - list: ProviderData[]; - resolutions: ProviderResolutionMap; -} +const hasCredentials = (resolution: ScannedResolution): resolution is ProviderResolution => + resolution.credentials !== null; -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: {} }; - } - - 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"); - resolutions.chatgpt = { - credentials: { - accountId: chatgptCredentials.accountId, - apiKey: chatgptCredentials.apiKey, - baseURL: chatgptCredentials.baseURL, - }, - fallbackModel: modelOverrides.fallbackModel, - lockedModel: modelOverrides.lockedModel, - providerType: "chatgpt", - }; - } - - const copilotCredentials = await resolveCopilotCredentials(client, directory); - if (copilotCredentials) { - const modelOverrides = resolveModelOverrides(list, "copilot"); - resolutions.copilot = { - credentials: copilotCredentials, - fallbackModel: modelOverrides.fallbackModel, - lockedModel: modelOverrides.lockedModel, - providerType: "copilot", - }; - } - - 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; + return []; + } + + const scanned = scanProviders(data.providers as ProviderData[]); + + /* + * 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 = scanned.find((resolution) => resolution.providerID === CANONICAL_OPENAI_ID); + if (canonical && !canonical.credentials?.baseURL) { + canonical.credentials = chatgpt; + canonical.type = "chatgpt"; + } } - if (resolutions.copilot) { - return true; + /* + * 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 = scanned.find((resolution) => resolution.providerID === CANONICAL_COPILOT_ID); + if (canonical && !canonical.credentials?.baseURL) { + canonical.credentials = copilot; + } else if (!canonical) { + scanned.push({ + credentials: copilot, + providerID: CANONICAL_COPILOT_ID, + type: "copilot", + }); + } } - return false; + return scanned.filter(hasCredentials); }; // ── 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: ProviderResolution[] | null = null; const activeModels = new Map(); return { @@ -388,26 +181,25 @@ 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 picked = pickModel(resolutions, active); - if (!resolved) { - return formatUnsupportedProviderError(active?.modelID ?? "unknown"); + if (!picked) { + return resolutions.length === NO_RESOLUTIONS + ? formatNoProviderError() + : formatUnsupportedProviderError(active?.modelID ?? "unknown"); } try { - return await dispatchSearch(resolved.providerType, resolved.config, args.query); + return await dispatchSearch( + picked.resolution.type, + { ...picked.resolution.credentials, model: picked.modelID }, + args.query, + ); } catch (error) { - return dispatchErrorMessage(resolved.providerType, error); + return dispatchErrorMessage(picked.resolution.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; } // ── Constants ────────────────────────────────────────────────────────── -const MOONSHOT_PROVIDER_ID = "moonshotai"; -const OPENAI_COMPATIBLE_PACKAGE = "@ai-sdk/openai-compatible"; - -const RESOLUTION_PRIORITY: ProviderType[] = [ - "anthropic", - "chatgpt", - "moonshot", - "openai", - "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; +/** + * 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`). + */ +const PROVIDER_TYPES_BY_ID: Record = { + anthropic: "anthropic", + "github-copilot": "copilot", + moonshotai: "moonshot", + "moonshotai-cn": "moonshot", + openai: "openai", }; -const detectProviderTypeFromNpm = (npmPackage: string): ProviderType | null => { - for (const rule of PROVIDER_RULES) { - if (rule.npmPackage === npmPackage) { - return rule.type; - } - } - - return null; +/** + * 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 NPM_TO_TYPE: Record = { + "@ai-sdk/anthropic": "anthropic", + "@ai-sdk/github-copilot": "copilot", + "@ai-sdk/openai": "openai", }; -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; - } +// ── Helpers ──────────────────────────────────────────────────────────── - if (model.api.npm !== OPENAI_COMPATIBLE_PACKAGE) { - return null; +/** + * 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 detectProviderType = (provider: ProviderDataLike): ScannableProviderType | null => { + const byID = PROVIDER_TYPES_BY_ID[provider.id]; + if (byID) { + return byID; } - if (isMoonshotProviderID(context.providerID)) { - return "moonshot"; + for (const model of Object.values(provider.models)) { + const byNpm = NPM_TO_TYPE[model.api.npm]; + if (byNpm) { + return byNpm; + } } return null; }; -export { - detectProviderTypeFromModel, - detectProviderTypeFromNpm, - detectProviderTypeFromProviderID, - RESOLUTION_PRIORITY, -}; +export { detectProviderType }; 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..258661d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -15,6 +15,13 @@ interface ProviderCredentials { */ type ProviderType = "anthropic" | "chatgpt" | "copilot" | "moonshot" | "openai"; +/** + * 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. + */ +type ScannableProviderType = Exclude; + /** * Fully resolved config for a single web search call: * credentials + the specific model to use. @@ -27,28 +34,23 @@ 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) - * - `providerType`: which provider this resolution belongs to + * 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; - providerType: ProviderType; -} - -/** - * Map of provider resolutions, one per supported provider type. - */ -interface ProviderResolutionMap { - anthropic?: ProviderResolution; - chatgpt?: ProviderResolution; - copilot?: ProviderResolution; - moonshot?: ProviderResolution; - openai?: ProviderResolution; + providerID: string; + type: ProviderType; } // ── Active Model ─────────────────────────────────────────────────────── @@ -62,15 +64,6 @@ interface ActiveModel { providerID: string; } -// ── Tool Args ────────────────────────────────────────────────────────── - -/** - * Arguments accepted by the web-search tool. - */ -interface SearchArgs { - query: string; -} - // ── Structured Result Types ──────────────────────────────────────────── /** @@ -95,9 +88,8 @@ export { ActiveModel, ProviderCredentials, ProviderResolution, - ProviderResolutionMap, ProviderType, - SearchArgs, + ScannableProviderType, SearchConfig, SearchHit, StructuredSearchResponse,