UNPKG

@gguf/claw

Version:

Multi-channel AI gateway with extensible messaging integrations

504 lines (502 loc) 16.7 kB
import { t as __exportAll } from "./rolldown-runtime-Cbj13DAv.js"; import { Gt as DEFAULT_PROVIDER, r as buildModelAliasIndex, s as modelKey } from "./model-selection-CqaTAlhy.js"; import { n as fetchWithTimeout } from "./fetch-timeout-DoySHqfO.js"; import { c as normalizeAlias } from "./shared-B-zggieW.js"; import { r as applyPrimaryModel } from "./model-picker-C9zrudfS.js"; //#region src/commands/onboard-custom.ts var onboard_custom_exports = /* @__PURE__ */ __exportAll({ CustomApiError: () => CustomApiError, applyCustomApiConfig: () => applyCustomApiConfig, parseNonInteractiveCustomApiFlags: () => parseNonInteractiveCustomApiFlags, promptCustomApiConfig: () => promptCustomApiConfig, resolveCustomProviderId: () => resolveCustomProviderId }); const DEFAULT_OLLAMA_BASE_URL = "http://127.0.0.1:11434/v1"; const DEFAULT_CONTEXT_WINDOW = 4096; const DEFAULT_MAX_TOKENS = 4096; const VERIFY_TIMEOUT_MS = 1e4; /** * Detects if a URL is from Azure AI Foundry or Azure OpenAI. * Matches both: * - https://*.services.ai.azure.com (Azure AI Foundry) * - https://*.openai.azure.com (classic Azure OpenAI) */ function isAzureUrl(baseUrl) { try { const host = new URL(baseUrl).hostname.toLowerCase(); return host.endsWith(".services.ai.azure.com") || host.endsWith(".openai.azure.com"); } catch { return false; } } /** * Transforms an Azure AI Foundry/OpenAI URL to include the deployment path. * Azure requires: https://host/openai/deployments/<model-id>/chat/completions?api-version=2024-xx-xx-preview * But we can't add query params here, so we just add the path prefix. * The api-version will be handled by the Azure OpenAI client or as a query param. * * Example: * https://my-resource.services.ai.azure.com + gpt-5-nano * => https://my-resource.services.ai.azure.com/openai/deployments/gpt-5-nano */ function transformAzureUrl(baseUrl, modelId) { const normalizedUrl = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl; if (normalizedUrl.includes("/openai/deployments/")) return normalizedUrl; return `${normalizedUrl}/openai/deployments/${modelId}`; } var CustomApiError = class extends Error { constructor(code, message) { super(message); this.name = "CustomApiError"; this.code = code; } }; const COMPATIBILITY_OPTIONS = [ { value: "openai", label: "OpenAI-compatible", hint: "Uses /chat/completions" }, { value: "anthropic", label: "Anthropic-compatible", hint: "Uses /messages" }, { value: "unknown", label: "Unknown (detect automatically)", hint: "Probes OpenAI then Anthropic endpoints" } ]; function normalizeEndpointId(raw) { const trimmed = raw.trim().toLowerCase(); if (!trimmed) return ""; return trimmed.replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, ""); } function buildEndpointIdFromUrl(baseUrl) { try { const url = new URL(baseUrl); return normalizeEndpointId(`custom-${url.hostname.replace(/[^a-z0-9]+/gi, "-").toLowerCase()}${url.port ? `-${url.port}` : ""}`) || "custom"; } catch { return "custom"; } } function resolveUniqueEndpointId(params) { const normalized = normalizeEndpointId(params.requestedId) || "custom"; const existing = params.providers[normalized]; if (!existing?.baseUrl || existing.baseUrl === params.baseUrl) return { providerId: normalized, renamed: false }; let suffix = 2; let candidate = `${normalized}-${suffix}`; while (params.providers[candidate]) { suffix += 1; candidate = `${normalized}-${suffix}`; } return { providerId: candidate, renamed: true }; } function resolveAliasError(params) { const trimmed = params.raw.trim(); if (!trimmed) return; let normalized; try { normalized = normalizeAlias(trimmed); } catch (err) { return err instanceof Error ? err.message : "Alias is invalid."; } const aliasIndex = buildModelAliasIndex({ cfg: params.cfg, defaultProvider: DEFAULT_PROVIDER }); const aliasKey = normalized.toLowerCase(); const existing = aliasIndex.byAlias.get(aliasKey); if (!existing) return; const existingKey = modelKey(existing.ref.provider, existing.ref.model); if (existingKey === params.modelRef) return; return `Alias ${normalized} already points to ${existingKey}.`; } function buildOpenAiHeaders(apiKey) { const headers = {}; if (apiKey) headers.Authorization = `Bearer ${apiKey}`; return headers; } function buildAnthropicHeaders(apiKey) { const headers = { "anthropic-version": "2023-06-01" }; if (apiKey) headers["x-api-key"] = apiKey; return headers; } function formatVerificationError(error) { if (!error) return "unknown error"; if (error instanceof Error) return error.message; if (typeof error === "string") return error; try { return JSON.stringify(error); } catch { return "unknown error"; } } function resolveVerificationEndpoint(params) { const resolvedUrl = isAzureUrl(params.baseUrl) ? transformAzureUrl(params.baseUrl, params.modelId) : params.baseUrl; const endpointUrl = new URL(params.endpointPath, resolvedUrl.endsWith("/") ? resolvedUrl : `${resolvedUrl}/`); if (isAzureUrl(params.baseUrl)) endpointUrl.searchParams.set("api-version", "2024-10-21"); return endpointUrl.href; } async function requestVerification(params) { try { const res = await fetchWithTimeout(params.endpoint, { method: "POST", headers: { "Content-Type": "application/json", ...params.headers }, body: JSON.stringify(params.body) }, VERIFY_TIMEOUT_MS); return { ok: res.ok, status: res.status }; } catch (error) { return { ok: false, error }; } } async function requestOpenAiVerification(params) { return await requestVerification({ endpoint: resolveVerificationEndpoint({ baseUrl: params.baseUrl, modelId: params.modelId, endpointPath: "chat/completions" }), headers: buildOpenAiHeaders(params.apiKey), body: { model: params.modelId, messages: [{ role: "user", content: "Hi" }], max_tokens: 5 } }); } async function requestAnthropicVerification(params) { return await requestVerification({ endpoint: resolveVerificationEndpoint({ baseUrl: /\/v1\/?$/.test(params.baseUrl.trim()) ? params.baseUrl.trim() : params.baseUrl.trim().replace(/\/?$/, "") + "/v1", modelId: params.modelId, endpointPath: "messages" }), headers: buildAnthropicHeaders(params.apiKey), body: { model: params.modelId, max_tokens: 16, messages: [{ role: "user", content: "Hi" }] } }); } async function promptBaseUrlAndKey(params) { const baseUrlInput = await params.prompter.text({ message: "API Base URL", initialValue: params.initialBaseUrl ?? DEFAULT_OLLAMA_BASE_URL, placeholder: "https://api.example.com/v1", validate: (val) => { try { new URL(val); return; } catch { return "Please enter a valid URL (e.g. http://...)"; } } }); const apiKeyInput = await params.prompter.text({ message: "API Key (leave blank if not required)", placeholder: "sk-...", initialValue: "" }); return { baseUrl: baseUrlInput.trim(), apiKey: apiKeyInput.trim() }; } async function promptCustomApiRetryChoice(prompter) { return await prompter.select({ message: "What would you like to change?", options: [ { value: "baseUrl", label: "Change base URL" }, { value: "model", label: "Change model" }, { value: "both", label: "Change base URL and model" } ] }); } async function promptCustomApiModelId(prompter) { return (await prompter.text({ message: "Model ID", placeholder: "e.g. llama3, claude-3-7-sonnet", validate: (val) => val.trim() ? void 0 : "Model ID is required" })).trim(); } function resolveProviderApi(compatibility) { return compatibility === "anthropic" ? "anthropic-messages" : "openai-completions"; } function parseCustomApiCompatibility(raw) { const compatibilityRaw = raw?.trim().toLowerCase(); if (!compatibilityRaw) return "openai"; if (compatibilityRaw !== "openai" && compatibilityRaw !== "anthropic") throw new CustomApiError("invalid_compatibility", "Invalid --custom-compatibility (use \"openai\" or \"anthropic\")."); return compatibilityRaw; } function resolveCustomProviderId(params) { const providers = params.config.models?.providers ?? {}; const baseUrl = params.baseUrl.trim(); const explicitProviderId = params.providerId?.trim(); if (explicitProviderId && !normalizeEndpointId(explicitProviderId)) throw new CustomApiError("invalid_provider_id", "Custom provider ID must include letters, numbers, or hyphens."); const requestedProviderId = explicitProviderId || buildEndpointIdFromUrl(baseUrl); const providerIdResult = resolveUniqueEndpointId({ requestedId: requestedProviderId, baseUrl, providers }); return { providerId: providerIdResult.providerId, ...providerIdResult.renamed ? { providerIdRenamedFrom: normalizeEndpointId(requestedProviderId) || "custom" } : {} }; } function parseNonInteractiveCustomApiFlags(params) { const baseUrl = params.baseUrl?.trim() ?? ""; const modelId = params.modelId?.trim() ?? ""; if (!baseUrl || !modelId) throw new CustomApiError("missing_required", ["Auth choice \"custom-api-key\" requires a base URL and model ID.", "Use --custom-base-url and --custom-model-id."].join("\n")); const apiKey = params.apiKey?.trim(); const providerId = params.providerId?.trim(); if (providerId && !normalizeEndpointId(providerId)) throw new CustomApiError("invalid_provider_id", "Custom provider ID must include letters, numbers, or hyphens."); return { baseUrl, modelId, compatibility: parseCustomApiCompatibility(params.compatibility), ...apiKey ? { apiKey } : {}, ...providerId ? { providerId } : {} }; } function applyCustomApiConfig(params) { const baseUrl = params.baseUrl.trim(); try { new URL(baseUrl); } catch { throw new CustomApiError("invalid_base_url", "Custom provider base URL must be a valid URL."); } if (params.compatibility !== "openai" && params.compatibility !== "anthropic") throw new CustomApiError("invalid_compatibility", "Custom provider compatibility must be \"openai\" or \"anthropic\"."); const modelId = params.modelId.trim(); if (!modelId) throw new CustomApiError("invalid_model_id", "Custom provider model ID is required."); const resolvedBaseUrl = isAzureUrl(baseUrl) ? transformAzureUrl(baseUrl, modelId) : baseUrl; const providerIdResult = resolveCustomProviderId({ config: params.config, baseUrl: resolvedBaseUrl, providerId: params.providerId }); const providerId = providerIdResult.providerId; const providers = params.config.models?.providers ?? {}; const modelRef = modelKey(providerId, modelId); const alias = params.alias?.trim() ?? ""; const aliasError = resolveAliasError({ raw: alias, cfg: params.config, modelRef }); if (aliasError) throw new CustomApiError("invalid_alias", aliasError); const existingProvider = providers[providerId]; const existingModels = Array.isArray(existingProvider?.models) ? existingProvider.models : []; const hasModel = existingModels.some((model) => model.id === modelId); const nextModel = { id: modelId, name: `${modelId} (Custom Provider)`, contextWindow: DEFAULT_CONTEXT_WINDOW, maxTokens: DEFAULT_MAX_TOKENS, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, reasoning: false }; const mergedModels = hasModel ? existingModels : [...existingModels, nextModel]; const { apiKey: existingApiKey, ...existingProviderRest } = existingProvider ?? {}; const normalizedApiKey = params.apiKey?.trim() || (existingApiKey ? existingApiKey.trim() : void 0); let config = { ...params.config, models: { ...params.config.models, mode: params.config.models?.mode ?? "merge", providers: { ...providers, [providerId]: { ...existingProviderRest, baseUrl: resolvedBaseUrl, api: resolveProviderApi(params.compatibility), ...normalizedApiKey ? { apiKey: normalizedApiKey } : {}, models: mergedModels.length > 0 ? mergedModels : [nextModel] } } } }; config = applyPrimaryModel(config, modelRef); if (alias) config = { ...config, agents: { ...config.agents, defaults: { ...config.agents?.defaults, models: { ...config.agents?.defaults?.models, [modelRef]: { ...config.agents?.defaults?.models?.[modelRef], alias } } } } }; return { config, providerId, modelId, ...providerIdResult.providerIdRenamedFrom ? { providerIdRenamedFrom: providerIdResult.providerIdRenamedFrom } : {} }; } async function promptCustomApiConfig(params) { const { prompter, runtime, config } = params; const baseInput = await promptBaseUrlAndKey({ prompter }); let baseUrl = baseInput.baseUrl; let apiKey = baseInput.apiKey; const compatibilityChoice = await prompter.select({ message: "Endpoint compatibility", options: COMPATIBILITY_OPTIONS.map((option) => ({ value: option.value, label: option.label, hint: option.hint })) }); let modelId = await promptCustomApiModelId(prompter); let compatibility = compatibilityChoice === "unknown" ? null : compatibilityChoice; while (true) { let verifiedFromProbe = false; if (!compatibility) { const probeSpinner = prompter.progress("Detecting endpoint type..."); if ((await requestOpenAiVerification({ baseUrl, apiKey, modelId })).ok) { probeSpinner.stop("Detected OpenAI-compatible endpoint."); compatibility = "openai"; verifiedFromProbe = true; } else if ((await requestAnthropicVerification({ baseUrl, apiKey, modelId })).ok) { probeSpinner.stop("Detected Anthropic-compatible endpoint."); compatibility = "anthropic"; verifiedFromProbe = true; } else { probeSpinner.stop("Could not detect endpoint type."); await prompter.note("This endpoint did not respond to OpenAI or Anthropic style requests.", "Endpoint detection"); const retryChoice = await promptCustomApiRetryChoice(prompter); if (retryChoice === "baseUrl" || retryChoice === "both") { const retryInput = await promptBaseUrlAndKey({ prompter, initialBaseUrl: baseUrl }); baseUrl = retryInput.baseUrl; apiKey = retryInput.apiKey; } if (retryChoice === "model" || retryChoice === "both") modelId = await promptCustomApiModelId(prompter); continue; } } if (verifiedFromProbe) break; const verifySpinner = prompter.progress("Verifying..."); const result = compatibility === "anthropic" ? await requestAnthropicVerification({ baseUrl, apiKey, modelId }) : await requestOpenAiVerification({ baseUrl, apiKey, modelId }); if (result.ok) { verifySpinner.stop("Verification successful."); break; } if (result.status !== void 0) verifySpinner.stop(`Verification failed: status ${result.status}`); else verifySpinner.stop(`Verification failed: ${formatVerificationError(result.error)}`); const retryChoice = await promptCustomApiRetryChoice(prompter); if (retryChoice === "baseUrl" || retryChoice === "both") { const retryInput = await promptBaseUrlAndKey({ prompter, initialBaseUrl: baseUrl }); baseUrl = retryInput.baseUrl; apiKey = retryInput.apiKey; } if (retryChoice === "model" || retryChoice === "both") modelId = await promptCustomApiModelId(prompter); if (compatibilityChoice === "unknown") compatibility = null; } const providers = config.models?.providers ?? {}; const suggestedId = buildEndpointIdFromUrl(baseUrl); const providerIdInput = await prompter.text({ message: "Endpoint ID", initialValue: suggestedId, placeholder: "custom", validate: (value) => { if (!normalizeEndpointId(value)) return "Endpoint ID is required."; } }); const aliasInput = await prompter.text({ message: "Model alias (optional)", placeholder: "e.g. local, ollama", initialValue: "", validate: (value) => { return resolveAliasError({ raw: value, cfg: config, modelRef: modelKey(resolveUniqueEndpointId({ requestedId: normalizeEndpointId(providerIdInput) || "custom", baseUrl, providers }).providerId, modelId) }); } }); const result = applyCustomApiConfig({ config, baseUrl, modelId, compatibility: compatibility ?? "openai", apiKey, providerId: providerIdInput, alias: aliasInput }); if (result.providerIdRenamedFrom && result.providerId) await prompter.note(`Endpoint ID "${result.providerIdRenamedFrom}" already exists for a different base URL. Using "${result.providerId}".`, "Endpoint ID"); runtime.log(`Configured custom provider: ${result.providerId}/${result.modelId}`); return result; } //#endregion export { promptCustomApiConfig as a, parseNonInteractiveCustomApiFlags as i, applyCustomApiConfig as n, resolveCustomProviderId as o, onboard_custom_exports as r, CustomApiError as t };