diff --git a/CHANGELOG.md b/CHANGELOG.md index 0080e32232..a08ec7a344 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ --- +## [3.1.10] — 2026-03-28 + +### 🐛 Bug Fixes + +- **Fix #706** — Fixed icon fallback rendering caused by Tailwind V4 `font-sans` override by applying `!important` to `.material-symbols-outlined`. +- **Fix #703** — Fixed GitHub Copilot broken streams by enabling `responses` to `openai` format translation for any custom models leveraging `apiFormat: "responses"`. +- **Fix #702** — Replaced flat-rate usage tracking with accurate DB pricing calculations for both streaming and non-streaming responses. +- **Fix #716** — Cleaned up Claude tool-call translation state, correctly parsing streaming arguments and preventing OpenAI `tool_calls` chunks from repeating the `id` field. + ## [3.1.9] — 2026-03-28 ### ✨ New Features @@ -31,12 +40,12 @@ ### 📁 New Files -| File | Purpose | -|------|---------| -| `open-sse/translator/helpers/schemaCoercion.ts` | Schema coercion and tool description sanitization utilities | -| `tests/unit/schema-coercion.test.mjs` | Unit tests for schema coercion | -| `tests/unit/t40-opencode-cli-tools-integration.test.mjs` | CLI tool integration tests | -| `COVERAGE_PLAN.md` | Test coverage planning document | +| File | Purpose | +| -------------------------------------------------------- | ----------------------------------------------------------- | +| `open-sse/translator/helpers/schemaCoercion.ts` | Schema coercion and tool description sanitization utilities | +| `tests/unit/schema-coercion.test.mjs` | Unit tests for schema coercion | +| `tests/unit/t40-opencode-cli-tools-integration.test.mjs` | CLI tool integration tests | +| `COVERAGE_PLAN.md` | Test coverage planning document | ## [3.1.8] - 2026-03-27 diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 97af13ca62..59254275b2 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: OmniRoute API - version: 3.1.9 + version: 3.1.10 description: | OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible endpoint that routes requests to multiple AI providers with load balancing, diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 061b1d9721..3e09795434 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -27,6 +27,8 @@ import { saveCallLog, } from "@/lib/usageDb"; import { getLoggedInputTokens, getLoggedOutputTokens } from "@/lib/usage/tokenAccounting"; +import { recordCost } from "@/domain/costRules"; +import { calculateCost } from "@/lib/usage/costCalculator"; import { CLAUDE_OAUTH_TOOL_PREFIX } from "../translator/request/openai-to-claude.ts"; import { getModelNormalizeToolCallId, @@ -1315,6 +1317,11 @@ export async function handleChatCore({ }); } + if (apiKeyInfo?.id && usage) { + const estimatedCost = await calculateCost(provider, model, usage); + if (estimatedCost > 0) recordCost(apiKeyInfo.id, estimatedCost); + } + // Translate response to client's expected format (usually OpenAI) // Pass toolNameMap so Claude OAuth proxy_ prefix is stripped in tool_use blocks (#605) let translatedResponse = needsTranslation(targetFormat, sourceFormat) @@ -1447,22 +1454,29 @@ export async function handleChatCore({ apiKeyName: apiKeyInfo?.name || null, noLog: apiKeyInfo?.noLog === true, }).catch(() => {}); + + if (apiKeyInfo?.id && streamUsage) { + calculateCost(provider, model, streamUsage) + .then((estimatedCost) => { + if (estimatedCost > 0) recordCost(apiKeyInfo.id, estimatedCost); + }) + .catch(() => {}); + } }; - // For Codex provider, translate response from openai-responses to openai (Chat Completions) format + // For providers using Responses API format, translate stream back to openai (Chat Completions) format // UNLESS client is Droid CLI which expects openai-responses format back const isDroidCLI = userAgent?.toLowerCase().includes("droid") || userAgent?.toLowerCase().includes("codex-cli"); - const needsCodexTranslation = - provider === "codex" && + const needsResponsesTranslation = targetFormat === FORMATS.OPENAI_RESPONSES && sourceFormat === FORMATS.OPENAI && !isResponsesEndpoint && !isDroidCLI; - if (needsCodexTranslation) { - // Codex returns openai-responses, translate to openai (Chat Completions) that clients expect - log?.debug?.("STREAM", `Codex translation mode: openai-responses → openai`); + if (needsResponsesTranslation) { + // Provider returns openai-responses, translate to openai (Chat Completions) that clients expect + log?.debug?.("STREAM", `Responses translation mode: openai-responses → openai`); transformStream = createSSETransformStreamWithLogger( "openai-responses", "openai", diff --git a/open-sse/translator/response/claude-to-openai.ts b/open-sse/translator/response/claude-to-openai.ts index ad928aa891..7101ae68a0 100644 --- a/open-sse/translator/response/claude-to-openai.ts +++ b/open-sse/translator/response/claude-to-openai.ts @@ -90,7 +90,6 @@ export function claudeToOpenAIResponse(chunk, state) { tool_calls: [ { index: toolCall.index, - id: toolCall.id, function: { arguments: delta.partial_json }, }, ], diff --git a/package-lock.json b/package-lock.json index 00c495a0f8..12bd2d8f95 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "omniroute", - "version": "3.1.9", + "version": "3.1.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute", - "version": "3.1.9", + "version": "3.1.10", "hasInstallScript": true, "license": "MIT", "workspaces": [ diff --git a/package.json b/package.json index a3f0c6af7e..c433f0f8ae 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "omniroute", - "version": "3.1.9", + "version": "3.1.10", "description": "Smart AI Router with auto fallback — route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.", "type": "module", "bin": { diff --git a/src/app/globals.css b/src/app/globals.css index 938e9e2905..c5f9362164 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -219,7 +219,7 @@ body { /* Material Symbols */ .material-symbols-outlined { - font-family: "Material Symbols Outlined", sans-serif; + font-family: "Material Symbols Outlined", sans-serif !important; font-weight: normal; font-style: normal; font-size: 24px; diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index 4ed55ec196..5cf38b297a 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -42,7 +42,6 @@ import { import { markAccountExhaustedFrom429 } from "../../domain/quotaCache"; import { RequestTelemetry, recordTelemetry } from "../../shared/utils/requestTelemetry"; import { generateRequestId } from "../../shared/utils/requestId"; -import { recordCost } from "../../domain/costRules"; import { logAuditEvent } from "../../lib/compliance/index"; import { enforceApiKeyPolicy } from "../../shared/utils/apiKeyPolicy"; import { @@ -421,7 +420,6 @@ async function handleSingleModelChat( if (result.success) { clearModelUnavailability(provider, model); - recordCostIfNeeded(apiKeyInfo, result); if (telemetry) telemetry.startPhase("finalize"); if (telemetry) telemetry.endPhase(); return result.response; @@ -670,18 +668,6 @@ async function executeChatWithBreaker({ } } -/** - * Record cost if API key has budget tracking enabled. - */ -function recordCostIfNeeded(apiKeyInfo: any, result: any) { - if (!apiKeyInfo?.id) return; - try { - const usage = result.usage || {}; - const estimatedCost = ((usage.prompt_tokens || 0) + (usage.completion_tokens || 0)) * 0.000001; - if (estimatedCost > 0) recordCost(apiKeyInfo.id, estimatedCost); - } catch {} -} - // ──── Extracted helpers (T-28) ──── function handleNoCredentials( diff --git a/tests/unit/model-combo-mappings-db.test.mjs b/tests/unit/model-combo-mappings-db.test.mjs index d95beba93a..6f3d080542 100644 --- a/tests/unit/model-combo-mappings-db.test.mjs +++ b/tests/unit/model-combo-mappings-db.test.mjs @@ -79,6 +79,8 @@ test("updateModelComboMapping merges fields and returns the refreshed mapping", priority: 1, }); + await new Promise((r) => setTimeout(r, 10)); + const updated = await mappingsDb.updateModelComboMapping(created.id, { pattern: "claude-*", comboId: comboB.id,