diff --git a/src/commands/auth-choice.apply.quotio.ts b/src/commands/auth-choice.apply.quotio.ts index 64b1fbbb7..601da3506 100644 --- a/src/commands/auth-choice.apply.quotio.ts +++ b/src/commands/auth-choice.apply.quotio.ts @@ -7,6 +7,7 @@ import { applyAuthProfileConfig } from "./onboard-auth.js"; const QUOTIO_DEFAULT_BASE_URL = "http://127.0.0.1:18317/v1"; const QUOTIO_DEFAULT_API_KEY = "quotio-local"; +const QUOTIO_PROBE_TIMEOUT_MS = 3000; type QuotioModel = { id: string; @@ -20,6 +21,86 @@ type QuotioModelsResponse = { data: QuotioModel[]; }; +type QuotioDetectionResult = { + baseUrl: string; + apiKey: string; + models: QuotioModel[]; + autoDetected: boolean; +}; + +function getEnvQuotioConfig(): { baseUrl?: string; apiKey?: string } { + return { + baseUrl: process.env.QUOTIO_BASE_URL || process.env.QUOTIO_URL, + apiKey: process.env.QUOTIO_API_KEY || process.env.QUOTIO_KEY, + }; +} + +async function probeQuotioEndpoint( + baseUrl: string, + apiKey: string, + timeoutMs: number = QUOTIO_PROBE_TIMEOUT_MS, +): Promise<{ ok: boolean; models: QuotioModel[] }> { + try { + const modelsUrl = baseUrl.endsWith("/") ? `${baseUrl}models` : `${baseUrl}/models`; + const headers: Record = { "Content-Type": "application/json" }; + if (apiKey) { + headers["Authorization"] = `Bearer ${apiKey}`; + } + + const response = await fetch(modelsUrl, { + headers, + signal: AbortSignal.timeout(timeoutMs), + }); + + if (!response.ok) { + return { ok: false, models: [] }; + } + + const data = (await response.json()) as QuotioModelsResponse; + if (!data.data || !Array.isArray(data.data) || data.data.length === 0) { + return { ok: false, models: [] }; + } + + return { ok: true, models: data.data }; + } catch { + return { ok: false, models: [] }; + } +} + +async function autoDetectQuotio(): Promise { + const env = getEnvQuotioConfig(); + + const baseUrl = env.baseUrl || QUOTIO_DEFAULT_BASE_URL; + const apiKey = env.apiKey || QUOTIO_DEFAULT_API_KEY; + + const result = await probeQuotioEndpoint(baseUrl, apiKey); + if (result.ok) { + return { + baseUrl, + apiKey, + models: result.models, + autoDetected: true, + }; + } + + if (env.baseUrl || env.apiKey) { + const defaultResult = await probeQuotioEndpoint( + QUOTIO_DEFAULT_BASE_URL, + QUOTIO_DEFAULT_API_KEY, + ); + if (defaultResult.ok) { + return { + baseUrl: QUOTIO_DEFAULT_BASE_URL, + apiKey: QUOTIO_DEFAULT_API_KEY, + models: defaultResult.models, + autoDetected: true, + }; + } + } + + return null; +} + async function discoverQuotioModels( baseUrl: string, apiKey: string, @@ -112,15 +193,111 @@ export async function applyAuthChoiceQuotio( let nextConfig = params.config; const agentDir = params.agentDir ?? resolveClawdbotAgentDir(); - await params.prompter.note( - [ - "Quotio is a local OpenAI-compatible proxy that routes to various AI models.", - "Make sure Quotio is running before continuing.", - "Default endpoint: http://127.0.0.1:18317/v1", - ].join("\n"), - "Quotio", - ); + await params.prompter.note("Detecting Quotio...", "Auto-detection"); + const detected = await autoDetectQuotio(); + + let finalBaseUrl: string; + let finalApiKey: string; + let discoveredModels: QuotioModel[]; + + if (detected) { + await params.prompter.note( + `Found Quotio at ${detected.baseUrl} with ${detected.models.length} model(s).`, + "Auto-detected", + ); + + const useDetected = await params.prompter.confirm({ + message: `Use detected configuration? (${detected.baseUrl})`, + initialValue: true, + }); + + if (useDetected) { + finalBaseUrl = detected.baseUrl; + finalApiKey = detected.apiKey; + discoveredModels = detected.models; + } else { + const manualConfig = await promptManualConfig(params); + if (!manualConfig) return { config: params.config }; + finalBaseUrl = manualConfig.baseUrl; + finalApiKey = manualConfig.apiKey; + discoveredModels = manualConfig.models; + } + } else { + await params.prompter.note( + [ + "Could not auto-detect Quotio.", + "Make sure Quotio is running, or configure manually.", + "Tip: Set QUOTIO_BASE_URL and QUOTIO_API_KEY environment variables for auto-detection.", + ].join("\n"), + "Not detected", + ); + + const manualConfig = await promptManualConfig(params); + if (!manualConfig) return { config: params.config }; + finalBaseUrl = manualConfig.baseUrl; + finalApiKey = manualConfig.apiKey; + discoveredModels = manualConfig.models; + } + + if (discoveredModels.length === 0) { + await params.prompter.note( + "No models found. Please check your Quotio configuration.", + "Setup Failed", + ); + return { config: params.config }; + } + + const modelOptions = discoveredModels.map((m) => ({ + value: m.id, + label: m.id, + hint: m.owned_by ? `by ${m.owned_by}` : undefined, + })); + + const selectedModelId = await params.prompter.select({ + message: "Select default model", + options: modelOptions, + }); + + const modelDefinitions = discoveredModels.map(buildModelDefinition); + const defaultModelRef = `quotio/${String(selectedModelId)}`; + + upsertAuthProfile({ + profileId: "quotio:default", + credential: { + type: "api_key", + provider: "quotio", + key: finalApiKey, + }, + agentDir, + }); + + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId: "quotio:default", + provider: "quotio", + mode: "api_key", + }); + + nextConfig = applyQuotioProviderConfig(nextConfig, finalBaseUrl, finalApiKey, modelDefinitions); + + let agentModelOverride: string | undefined; + if (params.setDefaultModel) { + nextConfig = applyQuotioDefaultModel(nextConfig, defaultModelRef); + await params.prompter.note(`Default model set to ${defaultModelRef}`, "Model configured"); + } else if (params.agentId) { + agentModelOverride = defaultModelRef; + await params.prompter.note( + `Default model set to ${defaultModelRef} for agent "${params.agentId}".`, + "Model configured", + ); + } + + return { config: nextConfig, agentModelOverride }; +} + +async function promptManualConfig( + params: ApplyAuthChoiceParams, +): Promise<{ baseUrl: string; apiKey: string; models: QuotioModel[] } | null> { const baseUrl = await params.prompter.text({ message: "Enter Quotio base URL", initialValue: QUOTIO_DEFAULT_BASE_URL, @@ -145,74 +322,15 @@ export async function applyAuthChoiceQuotio( await params.prompter.note("Discovering available models from Quotio...", "Connecting"); - const { models: discoveredModels, error } = await discoverQuotioModels( - normalizedBaseUrl, - normalizedApiKey, - ); + const { models, error } = await discoverQuotioModels(normalizedBaseUrl, normalizedApiKey); - if (error || discoveredModels.length === 0) { + if (error) { await params.prompter.note( - error - ? `Could not fetch models: ${error}\nPlease ensure Quotio is running and try again.` - : "No models found. Please check your Quotio configuration.", + `Could not fetch models: ${error}\nPlease ensure Quotio is running and try again.`, "Discovery Failed", ); - return { config: params.config }; + return null; } - await params.prompter.note( - `Found ${discoveredModels.length} model(s) available.`, - "Discovery Complete", - ); - - const modelOptions = discoveredModels.map((m) => ({ - value: m.id, - label: m.id, - hint: m.owned_by ? `by ${m.owned_by}` : undefined, - })); - - const selectedModelId = await params.prompter.select({ - message: "Select default model", - options: modelOptions, - }); - - const modelDefinitions = discoveredModels.map(buildModelDefinition); - const defaultModelRef = `quotio/${String(selectedModelId)}`; - - upsertAuthProfile({ - profileId: "quotio:default", - credential: { - type: "api_key", - provider: "quotio", - key: normalizedApiKey, - }, - agentDir, - }); - - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: "quotio:default", - provider: "quotio", - mode: "api_key", - }); - - nextConfig = applyQuotioProviderConfig( - nextConfig, - normalizedBaseUrl, - normalizedApiKey, - modelDefinitions, - ); - - let agentModelOverride: string | undefined; - if (params.setDefaultModel) { - nextConfig = applyQuotioDefaultModel(nextConfig, defaultModelRef); - await params.prompter.note(`Default model set to ${defaultModelRef}`, "Model configured"); - } else if (params.agentId) { - agentModelOverride = defaultModelRef; - await params.prompter.note( - `Default model set to ${defaultModelRef} for agent "${params.agentId}".`, - "Model configured", - ); - } - - return { config: nextConfig, agentModelOverride }; + return { baseUrl: normalizedBaseUrl, apiKey: normalizedApiKey, models }; }