mirror of https://github.com/openclaw/openclaw.git
191 lines
6.0 KiB
TypeScript
191 lines
6.0 KiB
TypeScript
import type { ModelDefinitionConfig } from "../config/types.js";
|
|
import { createSubsystemLogger } from "../logging/subsystem.js";
|
|
import {
|
|
KILOCODE_BASE_URL,
|
|
KILOCODE_DEFAULT_CONTEXT_WINDOW,
|
|
KILOCODE_DEFAULT_COST,
|
|
KILOCODE_DEFAULT_MAX_TOKENS,
|
|
KILOCODE_MODEL_CATALOG,
|
|
} from "../providers/kilocode-shared.js";
|
|
|
|
const log = createSubsystemLogger("kilocode-models");
|
|
|
|
export const KILOCODE_MODELS_URL = `${KILOCODE_BASE_URL}models`;
|
|
|
|
const DISCOVERY_TIMEOUT_MS = 5000;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Gateway response types (OpenRouter-compatible schema)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface GatewayModelPricing {
|
|
prompt: string;
|
|
completion: string;
|
|
image?: string;
|
|
request?: string;
|
|
input_cache_read?: string;
|
|
input_cache_write?: string;
|
|
web_search?: string;
|
|
internal_reasoning?: string;
|
|
}
|
|
|
|
interface GatewayModelEntry {
|
|
id: string;
|
|
name: string;
|
|
context_length: number;
|
|
architecture?: {
|
|
input_modalities?: string[];
|
|
output_modalities?: string[];
|
|
};
|
|
top_provider?: {
|
|
max_completion_tokens?: number | null;
|
|
};
|
|
pricing: GatewayModelPricing;
|
|
supported_parameters?: string[];
|
|
}
|
|
|
|
interface GatewayModelsResponse {
|
|
data: GatewayModelEntry[];
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Pricing conversion
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Convert per-token price (as returned by the gateway) to per-1M-token price
|
|
* (as stored in OpenClaw's ModelDefinitionConfig.cost).
|
|
*
|
|
* Gateway/OpenRouter prices are per-token strings like "0.000005".
|
|
* OpenClaw costs are per-1M-token numbers like 5.0.
|
|
*/
|
|
function toPricePerMillion(perToken: string | undefined): number {
|
|
if (!perToken) {
|
|
return 0;
|
|
}
|
|
const num = Number(perToken);
|
|
if (!Number.isFinite(num) || num < 0) {
|
|
return 0;
|
|
}
|
|
return num * 1_000_000;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Model parsing
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function parseModality(entry: GatewayModelEntry): Array<"text" | "image"> {
|
|
const modalities = entry.architecture?.input_modalities;
|
|
if (!Array.isArray(modalities)) {
|
|
return ["text"];
|
|
}
|
|
const hasImage = modalities.some((m) => typeof m === "string" && m.toLowerCase() === "image");
|
|
return hasImage ? ["text", "image"] : ["text"];
|
|
}
|
|
|
|
function parseReasoning(entry: GatewayModelEntry): boolean {
|
|
const params = entry.supported_parameters;
|
|
if (!Array.isArray(params)) {
|
|
return false;
|
|
}
|
|
return params.includes("reasoning") || params.includes("include_reasoning");
|
|
}
|
|
|
|
function toModelDefinition(entry: GatewayModelEntry): ModelDefinitionConfig {
|
|
return {
|
|
id: entry.id,
|
|
name: entry.name || entry.id,
|
|
reasoning: parseReasoning(entry),
|
|
input: parseModality(entry),
|
|
cost: {
|
|
input: toPricePerMillion(entry.pricing.prompt),
|
|
output: toPricePerMillion(entry.pricing.completion),
|
|
cacheRead: toPricePerMillion(entry.pricing.input_cache_read),
|
|
cacheWrite: toPricePerMillion(entry.pricing.input_cache_write),
|
|
},
|
|
contextWindow: entry.context_length || KILOCODE_DEFAULT_CONTEXT_WINDOW,
|
|
maxTokens: entry.top_provider?.max_completion_tokens ?? KILOCODE_DEFAULT_MAX_TOKENS,
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Static fallback
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function buildStaticCatalog(): ModelDefinitionConfig[] {
|
|
return KILOCODE_MODEL_CATALOG.map((model) => ({
|
|
id: model.id,
|
|
name: model.name,
|
|
reasoning: model.reasoning,
|
|
input: model.input,
|
|
cost: KILOCODE_DEFAULT_COST,
|
|
contextWindow: model.contextWindow ?? KILOCODE_DEFAULT_CONTEXT_WINDOW,
|
|
maxTokens: model.maxTokens ?? KILOCODE_DEFAULT_MAX_TOKENS,
|
|
}));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Discovery
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Discover models from the Kilo Gateway API with fallback to static catalog.
|
|
* The /api/gateway/models endpoint is public and doesn't require authentication.
|
|
*/
|
|
export async function discoverKilocodeModels(): Promise<ModelDefinitionConfig[]> {
|
|
// Skip API discovery in test environment
|
|
if (process.env.NODE_ENV === "test" || process.env.VITEST) {
|
|
return buildStaticCatalog();
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(KILOCODE_MODELS_URL, {
|
|
headers: { Accept: "application/json" },
|
|
signal: AbortSignal.timeout(DISCOVERY_TIMEOUT_MS),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
log.warn(`Failed to discover models: HTTP ${response.status}, using static catalog`);
|
|
return buildStaticCatalog();
|
|
}
|
|
|
|
const data = (await response.json()) as GatewayModelsResponse;
|
|
if (!Array.isArray(data.data) || data.data.length === 0) {
|
|
log.warn("No models found from gateway API, using static catalog");
|
|
return buildStaticCatalog();
|
|
}
|
|
|
|
const models: ModelDefinitionConfig[] = [];
|
|
const discoveredIds = new Set<string>();
|
|
|
|
for (const entry of data.data) {
|
|
if (!entry || typeof entry !== "object") {
|
|
continue;
|
|
}
|
|
const id = typeof entry.id === "string" ? entry.id.trim() : "";
|
|
if (!id || discoveredIds.has(id)) {
|
|
continue;
|
|
}
|
|
try {
|
|
models.push(toModelDefinition(entry));
|
|
discoveredIds.add(id);
|
|
} catch (e) {
|
|
log.warn(`Skipping malformed model entry "${id}": ${String(e)}`);
|
|
}
|
|
}
|
|
|
|
// Ensure the static fallback models are always present
|
|
const staticModels = buildStaticCatalog();
|
|
for (const staticModel of staticModels) {
|
|
if (!discoveredIds.has(staticModel.id)) {
|
|
models.unshift(staticModel);
|
|
}
|
|
}
|
|
|
|
return models.length > 0 ? models : buildStaticCatalog();
|
|
} catch (error) {
|
|
log.warn(`Discovery failed: ${String(error)}, using static catalog`);
|
|
return buildStaticCatalog();
|
|
}
|
|
}
|