Skip to content
Open
6 changes: 3 additions & 3 deletions src/cli/commands/proxy/__tests__/connect-orchestrator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,7 @@ describe('connectTargets — per-target dispatch, summary, partial-failure seman
const { writeDesktopConfig } = await import('../connectors/desktop.js');
const { writeVsCodeLanguageModelsConfig } = await import('../connectors/vscode.js');
vi.mocked(writeDesktopConfig).mockResolvedValue('/desktop/config.json');
vi.mocked(writeVsCodeLanguageModelsConfig).mockResolvedValue({ configPath: '/vscode/models.json', requiresSecretConfiguration: false });
vi.mocked(writeVsCodeLanguageModelsConfig).mockResolvedValue({ configPath: '/vscode/models.json', requiresSecretConfiguration: false, modelIds: ['model-a'] });
const { connectTargets } = await import('../connect-orchestrator.js');

await connectTargets({ targets: { claudeDesktop: true, vscode: true } });
Expand All @@ -319,7 +319,7 @@ describe('connectTargets — per-target dispatch, summary, partial-failure seman
const { writeDesktopConfig } = await import('../connectors/desktop.js');
const { writeVsCodeLanguageModelsConfig } = await import('../connectors/vscode.js');
vi.mocked(writeDesktopConfig).mockRejectedValue(new Error('disk full'));
vi.mocked(writeVsCodeLanguageModelsConfig).mockResolvedValue({ configPath: '/vscode/models.json', requiresSecretConfiguration: false });
vi.mocked(writeVsCodeLanguageModelsConfig).mockResolvedValue({ configPath: '/vscode/models.json', requiresSecretConfiguration: false, modelIds: ['model-a'] });
const { connectTargets } = await import('../connect-orchestrator.js');

await connectTargets({ targets: { claudeDesktop: true, vscode: true } });
Expand Down Expand Up @@ -381,7 +381,7 @@ describe('connectTargets — per-target dispatch, summary, partial-failure seman
it('single successful target still prints the per-target summary (spec §3.4)', async () => {
await withFreshDaemon();
const { writeVsCodeLanguageModelsConfig } = await import('../connectors/vscode.js');
vi.mocked(writeVsCodeLanguageModelsConfig).mockResolvedValue({ configPath: '/vscode/models.json', requiresSecretConfiguration: false });
vi.mocked(writeVsCodeLanguageModelsConfig).mockResolvedValue({ configPath: '/vscode/models.json', requiresSecretConfiguration: false, modelIds: ['model-a'] });
const { connectTargets } = await import('../connect-orchestrator.js');

await connectTargets({ targets: { vscode: true } });
Expand Down
3 changes: 3 additions & 0 deletions src/cli/commands/proxy/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,7 @@ describe('proxy connect vscode', () => {
vi.mocked(writeVsCodeLanguageModelsConfig).mockResolvedValue({
configPath: '/mock/chatLanguageModels.json',
requiresSecretConfiguration: false,
modelIds: ['gpt-4.1'],
});
});

Expand Down Expand Up @@ -450,6 +451,7 @@ describe('proxy connect vscode', () => {
expect(daemonOptions).not.toHaveProperty('model');
expect(writeVsCodeLanguageModelsConfig).toHaveBeenCalledWith(
'http://127.0.0.1:4001',
'local-key',
false
);
});
Expand Down Expand Up @@ -491,6 +493,7 @@ describe('proxy connect vscode', () => {
expect(spawnDaemon).not.toHaveBeenCalled();
expect(writeVsCodeLanguageModelsConfig).toHaveBeenCalledWith(
'http://127.0.0.1:4001',
'local-key',
false
);
});
Expand Down
7 changes: 3 additions & 4 deletions src/cli/commands/proxy/connect-orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ import {
import { fetchManagedMcpServers } from './connectors/managed-mcp-remote.js';
import { writeVsCodeClaudeCodeConfig } from './connectors/vscode-claude-code.js';
import { writeVsCodeLanguageModelsConfig } from './connectors/vscode.js';
import { VS_CODE_SUPPORTED_MODELS } from './connectors/vscode-models.js';
import { checkProxyHealth } from './health-check.js';
import {
discoverCodexModels,
Expand Down Expand Up @@ -442,15 +441,15 @@ async function runVscodeByok(
verbose: boolean
): Promise<TargetResult> {
try {
const result = await writeVsCodeLanguageModelsConfig(state.url, insiders);
const result = await writeVsCodeLanguageModelsConfig(state.url, state.gatewayKey, insiders);
logger.info(
'[proxy] VS Code BYOK configuration written',
...sanitizeLogArgs({
configPath: result.configPath,
gatewayUrl: state.url,
profile: state.profile,
project: state.project,
modelCount: VS_CODE_SUPPORTED_MODELS.length,
modelCount: result.modelIds.length,
clientType: state.clientType,
requiresSecretConfiguration: result.requiresSecretConfiguration,
})
Expand All @@ -459,7 +458,7 @@ async function runVscodeByok(
if (verbose) {
console.log(` Config: ${result.configPath}`);
console.log(` Gateway: ${state.url}`);
console.log(` Models: ${VS_CODE_SUPPORTED_MODELS.length}`);
console.log(` Models: ${result.modelIds.length}`);
console.log(` Project: ${config.codeMieProject || '(not configured)'}`);
}

Expand Down
6 changes: 5 additions & 1 deletion src/cli/commands/proxy/connectors/__tests__/vscode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,11 @@ describe('writeVsCodeLanguageModelsConfigAtPath', () => {
const provider = providers[0];
const models = provider.models as Array<Record<string, unknown>>;

expect(result).toEqual({ configPath, requiresSecretConfiguration: true });
expect(result).toEqual({
configPath,
requiresSecretConfiguration: true,
modelIds: [...EXPECTED_MODEL_IDS],
});
expect(provider).toMatchObject({
name: 'CodeMie',
vendor: 'customendpoint',
Expand Down
77 changes: 77 additions & 0 deletions src/cli/commands/proxy/connectors/gateway-models.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { ConfigurationError } from '@/utils/errors.js';
import { logger } from '@/utils/logger.js';
import { sanitizeLogArgs } from '@/utils/security.js';

/** SSO-backed catalog endpoint; `include_all` returns every model the tenant may use, not just the defaults. */
const MODELS_PATH = '/v1/llm_models?include_all=true';

interface ModelsListResponse {
data?: Array<{ id?: string }>;
}

interface CodeMieLlmModel {
id?: string;
base_name?: string;
deployment_name?: string;
}

function extractModelIds(json: ModelsListResponse | CodeMieLlmModel[]): string[] {
const ids = Array.isArray(json)
? json.map((model) => model.id || model.base_name || model.deployment_name)
: (json.data ?? []).map((model) => model.id);
return [...new Set(ids.filter((id): id is string => typeof id === 'string' && id.length > 0))];
}

/**
* Fetch every model ID the gateway serves for the active profile.
*
* Unlike the Claude Desktop path this applies no family filter — callers decide
* what to expose. Always throws {@link ConfigurationError} on failure so callers
* can pick their own fallback instead of silently receiving an empty catalog.
*/
export async function fetchGatewayModelIds(
proxyUrl: string,
gatewayKey: string
): Promise<string[]> {
const endpoint = new URL(MODELS_PATH, proxyUrl).toString();

let response: Response;
try {
response = await fetch(endpoint, {
headers: { Authorization: `Bearer ${gatewayKey}` },
});
} catch (error) {
throw new ConfigurationError(
`Local proxy model discovery could not reach ${endpoint}. ` +
`Reason: ${error instanceof Error ? error.message : String(error)}`
);
}

if (!response.ok) {
throw new ConfigurationError(
response.status === 401
? `Local proxy model discovery was rejected with 401 Unauthorized at ${endpoint}. ` +
'The local gateway key was not accepted by the proxy or was forwarded upstream incorrectly.'
: `Local proxy model discovery failed at ${endpoint}: ${response.status} ${response.statusText}`
);
}

const contentType = response.headers.get('content-type') ?? '';
if (!contentType.includes('application/json')) {
throw new ConfigurationError(
`Local proxy model discovery received an unexpected response (${contentType || 'no content-type'}) from ${endpoint}. ` +
'Your SSO session may have expired — run `codemie proxy stop && codemie profile login` to re-authenticate.'
);
}

const ids = extractModelIds(await response.json() as ModelsListResponse | CodeMieLlmModel[]);
logger.info(
'[proxy] Gateway model discovery completed',
...sanitizeLogArgs({
endpoint,
modelCount: ids.length,
models: ids,
})
);
return ids;
}
Loading
Loading