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 {
|
import {
|
||||||
evaluateOpenCodeSupport,
|
evaluateOpenCodeSupport,
|
||||||
OPENCODE_TEAM_LAUNCH_VERSION_POLICY,
|
OPENCODE_TEAM_LAUNCH_VERSION_POLICY,
|
||||||
|
|
@ -34,7 +36,7 @@ export interface OpenCodeRuntimeInventory {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OpenCodeModelExecutionProbeResult {
|
export interface OpenCodeModelExecutionProbeResult {
|
||||||
outcome: 'available' | 'unavailable' | 'unknown';
|
outcome: 'available' | 'unavailable' | 'not_authenticated' | 'unknown';
|
||||||
reason: string | null;
|
reason: string | null;
|
||||||
diagnostics: string[];
|
diagnostics: string[];
|
||||||
}
|
}
|
||||||
|
|
@ -103,8 +105,20 @@ const OPENCODE_RUNTIME_BINARY_UNREACHABLE_DIAGNOSTIC =
|
||||||
'OpenCode runtime binary is not installed or not reachable by launch preflight.';
|
'OpenCode runtime binary is not installed or not reachable by launch preflight.';
|
||||||
const OPENCODE_UNAUTHENTICATED_FREE_MODEL_DIAGNOSTIC =
|
const OPENCODE_UNAUTHENTICATED_FREE_MODEL_DIAGNOSTIC =
|
||||||
'No connected OpenCode provider found. Proceeding with a free OpenCode model route that does not require provider authentication.';
|
'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 =
|
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.';
|
'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 {
|
export class OpenCodeTeamLaunchReadinessService {
|
||||||
constructor(
|
constructor(
|
||||||
|
|
@ -155,8 +169,14 @@ export class OpenCodeTeamLaunchReadinessService {
|
||||||
|
|
||||||
const usingFreeModelWithoutProvider =
|
const usingFreeModelWithoutProvider =
|
||||||
!hasConnectedProvider && isFreeOpenCodeModelRoute(modelId);
|
!hasConnectedProvider && isFreeOpenCodeModelRoute(modelId);
|
||||||
|
const usingConfiguredAuthlessModelWithoutProvider =
|
||||||
|
!hasConnectedProvider && isConfiguredAuthlessOpenCodeModelRoute(modelId);
|
||||||
|
|
||||||
if (!hasConnectedProvider && !usingFreeModelWithoutProvider) {
|
if (
|
||||||
|
!hasConnectedProvider &&
|
||||||
|
!usingFreeModelWithoutProvider &&
|
||||||
|
!(usingConfiguredAuthlessModelWithoutProvider && input.requireExecutionProbe)
|
||||||
|
) {
|
||||||
return readiness({
|
return readiness({
|
||||||
state: 'not_authenticated',
|
state: 'not_authenticated',
|
||||||
inventory,
|
inventory,
|
||||||
|
|
@ -233,7 +253,10 @@ export class OpenCodeTeamLaunchReadinessService {
|
||||||
});
|
});
|
||||||
if (modelProbe.outcome !== 'available') {
|
if (modelProbe.outcome !== 'available') {
|
||||||
return readiness({
|
return readiness({
|
||||||
state: 'model_unavailable',
|
state:
|
||||||
|
modelProbe.outcome === 'not_authenticated'
|
||||||
|
? 'not_authenticated'
|
||||||
|
: 'model_unavailable',
|
||||||
inventory,
|
inventory,
|
||||||
modelId,
|
modelId,
|
||||||
capabilities,
|
capabilities,
|
||||||
|
|
@ -259,7 +282,9 @@ export class OpenCodeTeamLaunchReadinessService {
|
||||||
? appendDiagnostics(inventory.diagnostics, [
|
? appendDiagnostics(inventory.diagnostics, [
|
||||||
OPENCODE_UNAUTHENTICATED_FREE_MODEL_DIAGNOSTIC,
|
OPENCODE_UNAUTHENTICATED_FREE_MODEL_DIAGNOSTIC,
|
||||||
])
|
])
|
||||||
: inventory.diagnostics,
|
: usingConfiguredAuthlessModelWithoutProvider
|
||||||
|
? appendDiagnostics(inventory.diagnostics, [OPENCODE_AUTHLESS_LOCAL_MODEL_DIAGNOSTIC])
|
||||||
|
: inventory.diagnostics,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return readiness({
|
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: {
|
function readiness(input: {
|
||||||
state: OpenCodeTeamLaunchReadinessState;
|
state: OpenCodeTeamLaunchReadinessState;
|
||||||
inventory: OpenCodeRuntimeInventory | null;
|
inventory: OpenCodeRuntimeInventory | null;
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ export interface OpenCodeQualifiedModelRef {
|
||||||
raw: string;
|
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> = {
|
const OPEN_CODE_SOURCE_LABELS: Record<string, string> = {
|
||||||
anthropic: 'Anthropic',
|
anthropic: 'Anthropic',
|
||||||
|
|
@ -14,6 +14,10 @@ const OPEN_CODE_SOURCE_LABELS: Record<string, string> = {
|
||||||
gemini: 'Gemini',
|
gemini: 'Gemini',
|
||||||
google: 'Google',
|
google: 'Google',
|
||||||
groq: 'Groq',
|
groq: 'Groq',
|
||||||
|
'llama.cpp': 'llama.cpp',
|
||||||
|
llamacpp: 'llama.cpp',
|
||||||
|
lmstudio: 'LM Studio',
|
||||||
|
'lm-studio': 'LM Studio',
|
||||||
minimax: 'MiniMax',
|
minimax: 'MiniMax',
|
||||||
mistral: 'Mistral',
|
mistral: 'Mistral',
|
||||||
moonshot: 'Moonshot',
|
moonshot: 'Moonshot',
|
||||||
|
|
@ -24,6 +28,7 @@ const OPEN_CODE_SOURCE_LABELS: Record<string, string> = {
|
||||||
openrouter: 'OpenRouter',
|
openrouter: 'OpenRouter',
|
||||||
together: 'Together',
|
together: 'Together',
|
||||||
vertex: 'Vertex',
|
vertex: 'Vertex',
|
||||||
|
vllm: 'vLLM',
|
||||||
xai: 'xAI',
|
xai: 'xAI',
|
||||||
'z-ai': 'Z.AI',
|
'z-ai': 'Z.AI',
|
||||||
};
|
};
|
||||||
|
|
@ -40,7 +45,7 @@ function humanizeOpenCodeSourceId(sourceId: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
return normalized
|
return normalized
|
||||||
.split('-')
|
.split(/[-._]/g)
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
|
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
|
||||||
.join(' ');
|
.join(' ');
|
||||||
|
|
@ -50,19 +55,25 @@ export function parseOpenCodeQualifiedModelRef(
|
||||||
model: string | undefined | null
|
model: string | undefined | null
|
||||||
): OpenCodeQualifiedModelRef | null {
|
): OpenCodeQualifiedModelRef | null {
|
||||||
const trimmed = model?.trim();
|
const trimmed = model?.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed || /\s/.test(trimmed)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const match = OPEN_CODE_MODEL_REF_PATTERN.exec(trimmed);
|
const separatorIndex = trimmed.indexOf('/');
|
||||||
if (!match?.groups?.source || !match.groups.model) {
|
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 null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
raw: trimmed,
|
raw: `${sourceId}/${modelId}`,
|
||||||
sourceId: match.groups.source.toLowerCase(),
|
sourceId,
|
||||||
modelId: match.groups.model,
|
modelId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,91 @@ describe('OpenCodeTeamLaunchReadinessService', () => {
|
||||||
expect(ports.mcpTools.prove).not.toHaveBeenCalled();
|
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 () => {
|
it('blocks unsupported versions before MCP and model probes', async () => {
|
||||||
const ports = createPorts({
|
const ports = createPorts({
|
||||||
inventory: { version: '1.4.0' },
|
inventory: { version: '1.4.0' },
|
||||||
|
|
@ -271,7 +356,7 @@ function createPorts(
|
||||||
toolProof?: OpenCodeMcpToolProof;
|
toolProof?: OpenCodeMcpToolProof;
|
||||||
runtimeStores?: RuntimeStoreReadinessCheck;
|
runtimeStores?: RuntimeStoreReadinessCheck;
|
||||||
modelProbe?: {
|
modelProbe?: {
|
||||||
outcome: 'available' | 'unavailable' | 'unknown';
|
outcome: 'available' | 'unavailable' | 'not_authenticated' | 'unknown';
|
||||||
reason: string | null;
|
reason: string | null;
|
||||||
diagnostics: string[];
|
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