feat(opencode): support local model readiness
This commit is contained in:
parent
948e00aedb
commit
c3b6d2dea8
4 changed files with 175 additions and 13 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ export interface OpenCodeQualifiedModelRef {
|
|||
raw: string;
|
||||
}
|
||||
|
||||
const OPEN_CODE_MODEL_REF_PATTERN = /^(?<source>[a-z0-9-]+)\/(?<model>\S.*)$/i;
|
||||
const OPEN_CODE_SOURCE_ID_PATTERN = /^[a-z0-9._-]+$/i;
|
||||
|
||||
const OPEN_CODE_SOURCE_LABELS: Record<string, string> = {
|
||||
anthropic: 'Anthropic',
|
||||
|
|
@ -14,6 +14,10 @@ const OPEN_CODE_SOURCE_LABELS: Record<string, string> = {
|
|||
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<string, string> = {
|
|||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
};
|
||||
|
|
|
|||
36
test/shared/utils/opencodeModelRef.test.ts
Normal file
36
test/shared/utils/opencodeModelRef.test.ts
Normal file
|
|
@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue