diff --git a/src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness.ts b/src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness.ts index 7ebc877f..dd6277ef 100644 --- a/src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness.ts +++ b/src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness.ts @@ -1,3 +1,5 @@ +import { parseOpenCodeQualifiedModelRef } from '@shared/utils/opencodeModelRef'; + import { evaluateOpenCodeSupport, OPENCODE_TEAM_LAUNCH_VERSION_POLICY, @@ -34,7 +36,7 @@ export interface OpenCodeRuntimeInventory { } export interface OpenCodeModelExecutionProbeResult { - outcome: 'available' | 'unavailable' | 'unknown'; + outcome: 'available' | 'unavailable' | 'not_authenticated' | 'unknown'; reason: string | null; diagnostics: string[]; } @@ -103,8 +105,20 @@ const OPENCODE_RUNTIME_BINARY_UNREACHABLE_DIAGNOSTIC = 'OpenCode runtime binary is not installed or not reachable by launch preflight.'; const OPENCODE_UNAUTHENTICATED_FREE_MODEL_DIAGNOSTIC = 'No connected OpenCode provider found. Proceeding with a free OpenCode model route that does not require provider authentication.'; +const OPENCODE_AUTHLESS_LOCAL_MODEL_DIAGNOSTIC = + 'No connected OpenCode provider found. Proceeding with a configured local OpenCode model route after execution proof.'; const OPENCODE_UNAUTHENTICATED_PAID_MODEL_DIAGNOSTIC = 'No connected OpenCode provider found. Choose a free OpenCode model such as Big Pickle, or connect a provider in OpenCode for provider-backed models.'; +const AUTHLESS_LOCAL_OPEN_CODE_PROVIDER_IDS = new Set([ + 'atomic-chat', + 'llama.cpp', + 'llamacpp', + 'lmstudio', + 'lm-studio', + 'local', + 'ollama', + 'vllm', +]); export class OpenCodeTeamLaunchReadinessService { constructor( @@ -155,8 +169,14 @@ export class OpenCodeTeamLaunchReadinessService { const usingFreeModelWithoutProvider = !hasConnectedProvider && isFreeOpenCodeModelRoute(modelId); + const usingConfiguredAuthlessModelWithoutProvider = + !hasConnectedProvider && isConfiguredAuthlessOpenCodeModelRoute(modelId); - if (!hasConnectedProvider && !usingFreeModelWithoutProvider) { + if ( + !hasConnectedProvider && + !usingFreeModelWithoutProvider && + !(usingConfiguredAuthlessModelWithoutProvider && input.requireExecutionProbe) + ) { return readiness({ state: 'not_authenticated', inventory, @@ -233,7 +253,10 @@ export class OpenCodeTeamLaunchReadinessService { }); if (modelProbe.outcome !== 'available') { return readiness({ - state: 'model_unavailable', + state: + modelProbe.outcome === 'not_authenticated' + ? 'not_authenticated' + : 'model_unavailable', inventory, modelId, capabilities, @@ -259,7 +282,9 @@ export class OpenCodeTeamLaunchReadinessService { ? appendDiagnostics(inventory.diagnostics, [ OPENCODE_UNAUTHENTICATED_FREE_MODEL_DIAGNOSTIC, ]) - : inventory.diagnostics, + : usingConfiguredAuthlessModelWithoutProvider + ? appendDiagnostics(inventory.diagnostics, [OPENCODE_AUTHLESS_LOCAL_MODEL_DIAGNOSTIC]) + : inventory.diagnostics, }); } catch (error) { return readiness({ @@ -282,6 +307,11 @@ function isFreeOpenCodeModelRoute(modelId: string): boolean { ); } +function isConfiguredAuthlessOpenCodeModelRoute(modelId: string): boolean { + const parsed = parseOpenCodeQualifiedModelRef(modelId); + return parsed ? AUTHLESS_LOCAL_OPEN_CODE_PROVIDER_IDS.has(parsed.sourceId) : false; +} + function readiness(input: { state: OpenCodeTeamLaunchReadinessState; inventory: OpenCodeRuntimeInventory | null; diff --git a/src/shared/utils/opencodeModelRef.ts b/src/shared/utils/opencodeModelRef.ts index d6dfd5b1..45dd3ecd 100644 --- a/src/shared/utils/opencodeModelRef.ts +++ b/src/shared/utils/opencodeModelRef.ts @@ -4,7 +4,7 @@ export interface OpenCodeQualifiedModelRef { raw: string; } -const OPEN_CODE_MODEL_REF_PATTERN = /^(?[a-z0-9-]+)\/(?\S.*)$/i; +const OPEN_CODE_SOURCE_ID_PATTERN = /^[a-z0-9._-]+$/i; const OPEN_CODE_SOURCE_LABELS: Record = { anthropic: 'Anthropic', @@ -14,6 +14,10 @@ const OPEN_CODE_SOURCE_LABELS: Record = { gemini: 'Gemini', google: 'Google', groq: 'Groq', + 'llama.cpp': 'llama.cpp', + llamacpp: 'llama.cpp', + lmstudio: 'LM Studio', + 'lm-studio': 'LM Studio', minimax: 'MiniMax', mistral: 'Mistral', moonshot: 'Moonshot', @@ -24,6 +28,7 @@ const OPEN_CODE_SOURCE_LABELS: Record = { openrouter: 'OpenRouter', together: 'Together', vertex: 'Vertex', + vllm: 'vLLM', xai: 'xAI', 'z-ai': 'Z.AI', }; @@ -40,7 +45,7 @@ function humanizeOpenCodeSourceId(sourceId: string): string { } return normalized - .split('-') + .split(/[-._]/g) .filter(Boolean) .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) .join(' '); @@ -50,19 +55,25 @@ export function parseOpenCodeQualifiedModelRef( model: string | undefined | null ): OpenCodeQualifiedModelRef | null { const trimmed = model?.trim(); - if (!trimmed) { + if (!trimmed || /\s/.test(trimmed)) { return null; } - const match = OPEN_CODE_MODEL_REF_PATTERN.exec(trimmed); - if (!match?.groups?.source || !match.groups.model) { + const separatorIndex = trimmed.indexOf('/'); + if (separatorIndex <= 0 || separatorIndex >= trimmed.length - 1) { + return null; + } + + const sourceId = trimmed.slice(0, separatorIndex).toLowerCase(); + const modelId = trimmed.slice(separatorIndex + 1); + if (!OPEN_CODE_SOURCE_ID_PATTERN.test(sourceId) || !modelId) { return null; } return { - raw: trimmed, - sourceId: match.groups.source.toLowerCase(), - modelId: match.groups.model, + raw: `${sourceId}/${modelId}`, + sourceId, + modelId, }; } diff --git a/test/main/services/team/OpenCodeTeamLaunchReadiness.test.ts b/test/main/services/team/OpenCodeTeamLaunchReadiness.test.ts index d4efa6fb..2edfbe00 100644 --- a/test/main/services/team/OpenCodeTeamLaunchReadiness.test.ts +++ b/test/main/services/team/OpenCodeTeamLaunchReadiness.test.ts @@ -114,6 +114,91 @@ describe('OpenCodeTeamLaunchReadinessService', () => { expect(ports.mcpTools.prove).not.toHaveBeenCalled(); }); + it('requires execution probe before allowing unauthenticated configured local models', async () => { + const ports = createPorts({ + inventory: { + authenticated: false, + connectedProviders: [], + models: ['llama.cpp/qwen-test:0.5b'], + }, + }); + + await expect( + service(ports).check( + readinessInput({ + selectedModel: 'llama.cpp/qwen-test:0.5b', + requireExecutionProbe: false, + }) + ) + ).resolves.toMatchObject({ + state: 'not_authenticated', + launchAllowed: false, + modelId: 'llama.cpp/qwen-test:0.5b', + }); + expect(ports.modelExecution.verify).not.toHaveBeenCalled(); + }); + + it('allows unauthenticated configured local models after execution proof', async () => { + const ports = createPorts({ + inventory: { + authenticated: false, + connectedProviders: [], + models: ['llama.cpp/qwen-test:0.5b'], + }, + }); + + await expect( + service(ports).check( + readinessInput({ + selectedModel: 'llama.cpp/qwen-test:0.5b', + requireExecutionProbe: true, + }) + ) + ).resolves.toMatchObject({ + state: 'ready', + launchAllowed: true, + modelId: 'llama.cpp/qwen-test:0.5b', + diagnostics: [ + 'No connected OpenCode provider found. Proceeding with a configured local OpenCode model route after execution proof.', + ], + }); + expect(ports.modelExecution.verify).toHaveBeenCalledWith({ + projectPath: '/repo', + modelId: 'llama.cpp/qwen-test:0.5b', + inventory: expect.objectContaining({ + connectedProviders: [], + }), + }); + }); + + it('maps execution probe authentication failures to not_authenticated', async () => { + const ports = createPorts({ + inventory: { + authenticated: false, + connectedProviders: [], + models: ['llama.cpp/qwen-test:0.5b'], + }, + modelProbe: { + outcome: 'not_authenticated', + reason: 'local server rejected request', + diagnostics: ['local server rejected request'], + }, + }); + + await expect( + service(ports).check( + readinessInput({ + selectedModel: 'llama.cpp/qwen-test:0.5b', + requireExecutionProbe: true, + }) + ) + ).resolves.toMatchObject({ + state: 'not_authenticated', + launchAllowed: false, + missing: ['local server rejected request'], + }); + }); + it('blocks unsupported versions before MCP and model probes', async () => { const ports = createPorts({ inventory: { version: '1.4.0' }, @@ -271,7 +356,7 @@ function createPorts( toolProof?: OpenCodeMcpToolProof; runtimeStores?: RuntimeStoreReadinessCheck; modelProbe?: { - outcome: 'available' | 'unavailable' | 'unknown'; + outcome: 'available' | 'unavailable' | 'not_authenticated' | 'unknown'; reason: string | null; diagnostics: string[]; }; diff --git a/test/shared/utils/opencodeModelRef.test.ts b/test/shared/utils/opencodeModelRef.test.ts new file mode 100644 index 00000000..ce2ce383 --- /dev/null +++ b/test/shared/utils/opencodeModelRef.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; + +import { + getOpenCodeQualifiedModelSourceLabel, + parseOpenCodeQualifiedModelRef, +} from '../../../src/shared/utils/opencodeModelRef'; + +describe('opencodeModelRef', () => { + it('parses dotted providers and model ids with nested slashes', () => { + expect(parseOpenCodeQualifiedModelRef('llama.cpp/qwen3-coder:a3b')).toEqual({ + raw: 'llama.cpp/qwen3-coder:a3b', + sourceId: 'llama.cpp', + modelId: 'qwen3-coder:a3b', + }); + + expect(parseOpenCodeQualifiedModelRef('lmstudio/google/gemma-3n-e4b')).toEqual({ + raw: 'lmstudio/google/gemma-3n-e4b', + sourceId: 'lmstudio', + modelId: 'google/gemma-3n-e4b', + }); + }); + + it('rejects whitespace and unscoped OpenCode model refs', () => { + expect(parseOpenCodeQualifiedModelRef('llama.cpp/qwen test')).toBeNull(); + expect(parseOpenCodeQualifiedModelRef('qwen3-coder:a3b')).toBeNull(); + }); + + it('labels common local OpenCode providers', () => { + expect(getOpenCodeQualifiedModelSourceLabel('llama.cpp/qwen3-coder:a3b')).toBe( + 'llama.cpp' + ); + expect(getOpenCodeQualifiedModelSourceLabel('lmstudio/google/gemma-3n-e4b')).toBe( + 'LM Studio' + ); + }); +});