( model: ModelRef, wire: WireApi | undefined, baseUrl: string | undefined, httpHeaders?: Record<string, string> | undefined, apiKey?: string, )
| 262 | }; |
| 263 | |
| 264 | function buildPiModel( |
| 265 | model: ModelRef, |
| 266 | wire: WireApi | undefined, |
| 267 | baseUrl: string | undefined, |
| 268 | httpHeaders?: Record<string, string> | undefined, |
| 269 | apiKey?: string, |
| 270 | ): PiModel { |
| 271 | // Fall through to the canonical public endpoint for the 3 first-party |
| 272 | // BYOK providers when the caller omitted baseUrl. This is a fact about |
| 273 | // those endpoints (api.anthropic.com is anthropic), not a registry lookup for a |
| 274 | // model registry — imported / custom providers still require baseUrl and |
| 275 | // will throw if absent. |
| 276 | const resolvedBaseUrl = |
| 277 | baseUrl && baseUrl.trim().length > 0 |
| 278 | ? baseUrl |
| 279 | : (BUILTIN_PUBLIC_BASE_URLS[model.provider] ?? ''); |
| 280 | if (resolvedBaseUrl.length === 0) { |
| 281 | throw new CodesignError( |
| 282 | `Provider "${model.provider}" has no baseUrl configured. Add one in Settings or re-import the config.`, |
| 283 | ERROR_CODES.PROVIDER_BASE_URL_MISSING, |
| 284 | ); |
| 285 | } |
| 286 | // Defensive: canonicalize stored baseUrl before handing to pi-ai. Rescues |
| 287 | // legacy configs that persisted pre-normalization (e.g. raw `/v1/chat/completions` |
| 288 | // pasted in an older build). No-op for configs saved post-fix. |
| 289 | // For openai-codex-responses, canonicalBaseUrl only strips trailing slashes |
| 290 | // — pi-ai's codex wire appends `/codex/responses` from the bare base itself. |
| 291 | const canonicalBase = wire ? canonicalBaseUrl(resolvedBaseUrl, wire) : resolvedBaseUrl; |
| 292 | const effectiveModelId = normalizeGeminiModelId(model.modelId, canonicalBase); |
| 293 | const out: PiModel = { |
| 294 | id: effectiveModelId, |
| 295 | name: effectiveModelId, |
| 296 | api: apiForWire(wire), |
| 297 | provider: model.provider, |
| 298 | baseUrl: canonicalBase, |
| 299 | reasoning: inferReasoning(wire, effectiveModelId, canonicalBase), |
| 300 | input: supportsImageInput(wire, effectiveModelId) ? ['text', 'image'] : ['text'], |
| 301 | cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, |
| 302 | contextWindow: 200000, |
| 303 | maxTokens: 32000, |
| 304 | }; |
| 305 | const compat = openAIChatCompatForBaseUrl(wire, canonicalBase); |
| 306 | if (compat !== undefined) out.compat = compat; |
| 307 | if (httpHeaders !== undefined) out.headers = httpHeaders; |
| 308 | |
| 309 | // sub2api / claude2api gateways 403 any request without claude-cli |
| 310 | // identity headers. pi-ai only emits them for sk-ant-oat OAuth tokens — |
| 311 | // so a custom anthropic baseUrl keyed by a plain token hits the edge WAF. |
| 312 | // Inject them here too (this path goes through pi-agent-core, which |
| 313 | // forwards model.headers to pi-ai). User-supplied headers keep precedence. |
| 314 | // Skip when the key already looks OAuth-shaped: pi-ai's OAuth branch |
| 315 | // injects the same set, and leaving that the single source keeps us from |
| 316 | // silently overriding future pi-ai header updates on the OAuth path. |
| 317 | if ( |
| 318 | shouldForceClaudeCodeIdentity(wire, canonicalBase) && |
| 319 | (apiKey === undefined || !looksLikeClaudeOAuthToken(apiKey)) |
| 320 | ) { |
| 321 | out.headers = { ...claudeCodeIdentityHeaders(), ...(out.headers ?? {}) }; |
no test coverage detected