Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ Connect OpenCode to a running CLIProxyAPI instance (local or remote), authentica
## Features

- **`/connect cliproxy`** — interactive setup (base URL + optional API key)
- **Provider `cliproxy`** — auto-registered with full model list
- **Configurable provider ID** — defaults to `cliproxy` for backwards compatibility
- **Dynamic models** — fetched from CLIProxyAPI `/v1/models` with TTL cache
- **Server-authoritative rich catalog** — optional `client_version` catalog with exact capabilities and reasoning levels
- **models.json enrichment** — defaults to CLIProxyAPI registry URL; override with local path or custom URL
- **models.dev enrichment** — fills missing metadata (graceful fallback)
- **Auth-aware base URL** — `/connect cliproxy` base URL used when `opencode.json` omits `baseURL`
Expand Down Expand Up @@ -110,15 +111,18 @@ Optional settings in `opencode.json`:

```json
{
"plugin": ["opencode-cliproxiapi-auth"],
"plugin": [["opencode-cliproxiapi-auth", { "providerId": "cliproxyapi" }]],
"provider": {
"cliproxy": {
"cliproxyapi": {
"options": {
"baseURL": "http://localhost:8317/v1",
"apiKey": "your-key-from-config.yaml",
"modelCacheTtl": 300000,
"refreshOnList": true,
"modelsDev": { "enabled": true }
"modelsClientVersion": "0.144.1",
"fabricatedFallback": false,
"modelsJsonPath": "",
"modelsDev": { "enabled": false }
}
}
}
Expand Down Expand Up @@ -159,10 +163,13 @@ Enrichment mapping:

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `baseURL` | string | `http://localhost:8317/v1` | CLIProxyAPI base URL; falls back to `/connect cliproxy` stored URL |
| `providerId` | string | `cliproxy` | Provider/auth namespace configured in the plugin tuple |
| `baseURL` | string | `http://localhost:8317/v1` | CLIProxyAPI base URL; falls back to the stored `/connect <providerId>` URL |
| `apiKey` | string | — | Key from `config.yaml` `api-keys` (optional) |
| `modelCacheTtl` | number | `300000` | Model cache TTL (ms) |
| `refreshOnList` | boolean | `true` | Refresh models when provider options reload |
| `modelsClientVersion` | string | — | Adds `client_version` to `/v1/models` and parses the server's rich `models` catalog |
| `fabricatedFallback` | boolean | `true` | Set `false` to return no invented built-in models when no live/stale catalog exists |
| `modelsJsonPath` | string | CLIProxyAPI GitHub `models.json` | Local path or URL; `""` disables enrichment |
| `modelsDev.enabled` | boolean | `true` | Enrich from models.dev |
| `modelsDev.url` | string | `https://models.dev/api.json` | models.dev API URL |
Expand Down
34 changes: 22 additions & 12 deletions src/model-fetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ function getCacheKey(config: CliproxyConfig): string {
providerAliases: config.modelsDev.providerAliases,
})
: '';
return `${config.baseUrl}:${config.apiKey}:${config.modelsJsonPath ?? ''}:${modelsDevHash}`;
return `${config.baseUrl}:${config.apiKey}:${config.modelsClientVersion ?? ''}:${config.modelsJsonPath ?? ''}:${config.fabricatedFallback ?? true}:${modelsDevHash}`;
}

function buildAuthHeaders(apiKey: string): HeadersInit {
Expand All @@ -40,15 +40,18 @@ async function fetchApiModels(
config: CliproxyConfig,
): Promise<CliproxyModel[]> {
const baseUrl = config.baseUrl || CLIPROXY_ENDPOINTS.BASE_URL;
const modelsUrl = `${baseUrl}${CLIPROXY_ENDPOINTS.MODELS}`;
const modelsUrl = new URL(`${baseUrl}${CLIPROXY_ENDPOINTS.MODELS}`);
if (config.modelsClientVersion) {
modelsUrl.searchParams.set('client_version', config.modelsClientVersion);
}

debug(`Fetching models from ${modelsUrl}`);
debug(`Fetching models from ${modelsUrl.toString()}`);

const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT);

try {
const response = await fetch(modelsUrl, {
const response = await fetch(modelsUrl.toString(), {
method: 'GET',
headers: buildAuthHeaders(config.apiKey),
signal: controller.signal,
Expand All @@ -60,18 +63,20 @@ async function fetchApiModels(
}

const rawData: unknown = await response.json();
if (
!rawData ||
typeof rawData !== 'object' ||
!Array.isArray((rawData as CliproxyModelsResponse).data)
) {
if (!rawData || typeof rawData !== 'object') {
throw new Error('Invalid models response structure');
}

const data = rawData as CliproxyModelsResponse;
return data.data
.filter((m) => m && typeof m.id === 'string')
.map(normalizeApiModel);
const entries = Array.isArray(data.models) ? data.models : data.data;
if (!Array.isArray(entries)) {
throw new Error('Invalid models response structure');
}

return entries
.filter((model) => model && typeof model === 'object')
.map(normalizeApiModel)
.filter((model) => model.id.length > 0);
} finally {
clearTimeout(timeoutId);
}
Expand Down Expand Up @@ -113,6 +118,11 @@ async function fetchModelsUncached(config: CliproxyConfig): Promise<CliproxyMode
return stale.value;
}

if (config.fabricatedFallback === false) {
debug('Fabricated fallback models disabled');
return [];
}

debug('Returning default models as fallback');
return config.defaultModels ?? CLIPROXY_DEFAULT_MODELS;
}
Expand Down
157 changes: 118 additions & 39 deletions src/normalizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,7 @@ import type {
CliproxyThinking,
} from './types.js';

const REASONING_LEVELS = new Set([
'minimal',
'low',
'medium',
'high',
'xhigh',
'max',
]);
const REASONING_LEVEL_PATTERN = /^[a-z][a-z0-9_-]*$/;

/** Map CLIProxyAPI thinking config to OpenCode reasoning variants */
export function thinkingToVariants(
Expand All @@ -30,33 +23,97 @@ export function thinkingToVariants(

const variants: Record<string, CliproxyModelVariant> = {};
for (const level of levels) {
const key = level.toLowerCase();
if (REASONING_LEVELS.has(key)) {
// Variant key is the reasoning level; config schema allows only `disabled`.
variants[key] = {};
const key = level.toLowerCase().trim();
if (REASONING_LEVEL_PATTERN.test(key)) {
variants[key] = { reasoningEffort: key };
}
}

return Object.keys(variants).length > 0 ? variants : undefined;
}

/** Normalize a /v1/models entry */
export function normalizeApiModel(model: {
id: string;
owned_by?: string;
[key: string]: unknown;
}): CliproxyModel {
/** Normalize a minimal OpenAI or rich Codex-compatible /v1/models entry. */
export function normalizeApiModel(model: Record<string, unknown>): CliproxyModel {
const id = getString(model.id) ?? getString(model.slug) ?? '';
const inputModalities = getStringArray(model.input_modalities);
const levels = getReasoningLevels(model.supported_reasoning_levels);
const directThinking = isRecord(model.thinking) ? normalizeThinking(model.thinking) : undefined;
const thinking = directThinking ?? (levels.length > 0 ? { levels } : undefined);
const variants = thinkingToVariants(thinking);
const supportsTools = getBoolean(model.supports_tool_calls) ??
getBoolean(model.supports_parallel_tool_calls);
const serverAuthoritative = [
model.display_name,
model.max_context_window,
model.input_modalities,
model.supported_reasoning_levels,
].some((value) => value !== undefined);

return {
id: model.id,
name: typeof model.name === 'string' ? model.name : model.id,
ownedBy: typeof model.owned_by === 'string' ? model.owned_by : undefined,
description: `CLIProxyAPI model: ${model.id}`,
supportsStreaming: true,
supportsTools: true,
supportsTemperature: true,
id,
name: getString(model.display_name) ?? getString(model.name) ?? id,
ownedBy: getString(model.owned_by),
description: getString(model.description) ?? `CLIProxyAPI model: ${id}`,
contextWindow: getNumber(model.max_context_window) ??
getNumber(model.context_window) ??
getNumber(model.context_length) ??
getNumber(model.inputTokenLimit),
maxTokens: getNumber(model.max_completion_tokens) ?? getNumber(model.outputTokenLimit),
supportsStreaming: getBoolean(model.supports_streaming) ?? true,
supportsVision: inputModalities.length > 0
? inputModalities.includes('image')
: getBoolean(model.supports_vision),
supportsTools,
supportsTemperature: getBoolean(model.supports_temperature),
supportsReasoning: thinking ? true : getBoolean(model.supports_reasoning),
supportsAttachment: inputModalities.length > 0
? inputModalities.includes('image')
: getBoolean(model.supports_attachment),
thinking,
variants,
serverAuthoritative,
};
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

function getString(value: unknown): string | undefined {
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
}

function getNumber(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
}

function getBoolean(value: unknown): boolean | undefined {
return typeof value === 'boolean' ? value : undefined;
}

function getStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.map(getString).filter((entry): entry is string => entry !== undefined);
}

function getReasoningLevels(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value
.map((entry) => getString(entry) ?? (isRecord(entry) ? getString(entry.effort) : undefined))
.filter((entry): entry is string => entry !== undefined);
}

function normalizeThinking(value: Record<string, unknown>): CliproxyThinking | undefined {
const thinking: CliproxyThinking = {
min: getNumber(value.min),
max: getNumber(value.max),
zero_allowed: getBoolean(value.zero_allowed),
dynamic_allowed: getBoolean(value.dynamic_allowed),
levels: getStringArray(value.levels),
};
return Object.values(thinking).some((entry) => entry !== undefined) ? thinking : undefined;
}

/** Normalize a models.json registry entry with rich metadata */
export function normalizeRegistryModel(model: CliproxyRegistryModel): CliproxyModel {
const contextWindow =
Expand Down Expand Up @@ -87,29 +144,51 @@ export function normalizeRegistryModel(model: CliproxyRegistryModel): CliproxyMo
};
}

/** Merge registry metadata into API model (registry wins for known fields) */
/** Fill missing API metadata from an optional registry; live rich fields remain authoritative. */
export function mergeModelMetadata(
apiModel: CliproxyModel,
registry?: CliproxyModel,
): CliproxyModel {
if (!registry) return apiModel;

if (!apiModel.serverAuthoritative) {
return {
...apiModel,
...registry,
id: apiModel.id,
name: registry.name || apiModel.name,
description: registry.description || apiModel.description,
supportsStreaming: registry.supportsStreaming ?? apiModel.supportsStreaming,
supportsTools: registry.supportsTools ?? apiModel.supportsTools,
supportsTemperature: registry.supportsTemperature ?? apiModel.supportsTemperature,
supportsReasoning: registry.supportsReasoning ?? apiModel.supportsReasoning,
supportsVision: registry.supportsVision ?? apiModel.supportsVision,
supportsAttachment: registry.supportsAttachment ?? apiModel.supportsAttachment,
contextWindow: registry.contextWindow ?? apiModel.contextWindow,
maxTokens: registry.maxTokens ?? apiModel.maxTokens,
thinking: registry.thinking ?? apiModel.thinking,
variants: registry.variants ?? apiModel.variants,
};
}

return {
...apiModel,
...registry,
...apiModel,
id: apiModel.id,
name: registry.name || apiModel.name,
description: registry.description || apiModel.description,
supportsStreaming: registry.supportsStreaming ?? apiModel.supportsStreaming,
supportsTools: registry.supportsTools ?? apiModel.supportsTools,
supportsTemperature: registry.supportsTemperature ?? apiModel.supportsTemperature,
supportsReasoning: registry.supportsReasoning ?? apiModel.supportsReasoning,
supportsVision: registry.supportsVision ?? apiModel.supportsVision,
supportsAttachment: registry.supportsAttachment ?? apiModel.supportsAttachment,
contextWindow: registry.contextWindow ?? apiModel.contextWindow,
maxTokens: registry.maxTokens ?? apiModel.maxTokens,
thinking: registry.thinking ?? apiModel.thinking,
variants: registry.variants ?? apiModel.variants,
name: apiModel.name && apiModel.name !== apiModel.id ? apiModel.name : registry.name || apiModel.name,
description: apiModel.description && apiModel.description !== `CLIProxyAPI model: ${apiModel.id}`
? apiModel.description
: registry.description || apiModel.description,
supportsStreaming: apiModel.supportsStreaming ?? registry.supportsStreaming,
supportsTools: apiModel.supportsTools ?? registry.supportsTools,
supportsTemperature: apiModel.supportsTemperature ?? registry.supportsTemperature,
supportsReasoning: apiModel.supportsReasoning ?? registry.supportsReasoning,
supportsVision: apiModel.supportsVision ?? registry.supportsVision,
supportsAttachment: apiModel.supportsAttachment ?? registry.supportsAttachment,
contextWindow: apiModel.contextWindow ?? registry.contextWindow,
maxTokens: apiModel.maxTokens ?? registry.maxTokens,
thinking: apiModel.thinking ?? registry.thinking,
variants: apiModel.variants ?? registry.variants,
};
}

Expand Down
Loading