From 20c661235c999885433bde5bc400dd334d737968 Mon Sep 17 00:00:00 2001 From: Pedro Ivo Date: Wed, 23 Sep 2026 00:07:04 -0300 Subject: [PATCH] feat: show Jev-selected model in CLI response metadata --- README.md | 4 ++ package.json | 2 +- src/components/MessageModel.test.tsx | 20 +++++++++ src/components/MessageModel.tsx | 62 ++++++++++++---------------- src/components/MessageRow.tsx | 2 +- src/services/api/openaiShim.test.ts | 32 ++++++++++++++ src/services/api/openaiShim.ts | 37 +++++++++++++++-- 7 files changed, 117 insertions(+), 42 deletions(-) create mode 100644 src/components/MessageModel.test.tsx diff --git a/README.md b/README.md index cdec09958b..15b6223586 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,10 @@ approval flow. ## Agent Model Routing +Ao usar `jev-router`, a CLI mostra o modelo físico escolhido em cada resposta +como `jev-router → glm-5.3-flash` (exemplo). O alias solicitado permanece no +protocolo; a escolha vem do metadado da resposta do roteador. + Verboo resolves subagent models against the authenticated `/models` catalog and preserves the complete server-provided ID, including plan prefixes such as `max/qwen3.6-27b`. Router-provided `agent_model_roles` are authoritative; the diff --git a/package.json b/package.json index f692c4b7c2..fb28850b75 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@verboo/code", - "version": "0.15.30", + "version": "0.15.31", "description": "Verboo Code — coding agent for the Verboo platform", "type": "module", "bin": { diff --git a/src/components/MessageModel.test.tsx b/src/components/MessageModel.test.tsx new file mode 100644 index 0000000000..6f50a79746 --- /dev/null +++ b/src/components/MessageModel.test.tsx @@ -0,0 +1,20 @@ +import { expect, test } from 'bun:test' +import React from 'react' +import { renderToString } from '../utils/staticRender.js' +import { MessageModel } from './MessageModel.js' + +test('shows the physical Jev choice during a normal CLI conversation', async () => { + const message = { + type: 'assistant', + message: { + model: 'jev-router', + metadata: { selected_model: 'glm-5.3-flash' }, + content: [{ type: 'text', text: 'ok' }], + }, + } + const rendered = await renderToString( + , + 80, + ) + expect(rendered).toContain('jev-router → glm-5.3-flash') +}) diff --git a/src/components/MessageModel.tsx b/src/components/MessageModel.tsx index da05ad1fd5..b35b774d18 100644 --- a/src/components/MessageModel.tsx +++ b/src/components/MessageModel.tsx @@ -1,42 +1,32 @@ -import { c as _c } from "react-compiler-runtime"; -import React from 'react'; -import { stringWidth } from '../ink/stringWidth.js'; -import { Box, Text } from '../ink.js'; -import type { NormalizedMessage } from '../types/message.js'; +import React from 'react' +import { stringWidth } from '../ink/stringWidth.js' +import { Box, Text } from '../ink.js' +import type { NormalizedMessage } from '../types/message.js' type Props = { message: NormalizedMessage; isTranscriptMode: boolean; }; -export function MessageModel(t0) { - const $ = _c(5); - const { - message, - isTranscriptMode - } = t0; - const shouldShowModel = isTranscriptMode && message.type === "assistant" && message.message.model && message.message.content.some(_temp); - if (!shouldShowModel) { - return null; +export function MessageModel({ message, isTranscriptMode }: Props) { + if ( + message.type !== 'assistant' || + message.message.content.length === 0 + ) { + return null } - const t1 = stringWidth(message.message.model) + 8; - let t2; - if ($[0] !== message.message.model) { - t2 = {message.message.model}; - $[0] = message.message.model; - $[1] = t2; - } else { - t2 = $[1]; - } - let t3; - if ($[2] !== t1 || $[3] !== t2) { - t3 = {t2}; - $[2] = t1; - $[3] = t2; - $[4] = t3; - } else { - t3 = $[4]; - } - return t3; -} -function _temp(c) { - return c.type === "text"; + + const requested = message.message.model + const selected = message.message.metadata?.selected_model + const display = + typeof selected === 'string' && selected.length > 0 + ? `${requested} → ${selected}` + : isTranscriptMode + ? requested + : null + if (!display) return null + + return ( + + {display} + + ) } diff --git a/src/components/MessageRow.tsx b/src/components/MessageRow.tsx index 4c47ffa240..0fbd24f490 100644 --- a/src/components/MessageRow.tsx +++ b/src/components/MessageRow.tsx @@ -218,7 +218,7 @@ function MessageRowImpl(t0) { } let t5; if ($[34] !== displayMsg || $[35] !== isTranscriptMode) { - t5 = isTranscriptMode && displayMsg.type === "assistant" && displayMsg.message.content.some(_temp) && (displayMsg.timestamp || displayMsg.message.model); + t5 = displayMsg.type === "assistant" && (isTranscriptMode && displayMsg.message.content.some(_temp) && (displayMsg.timestamp || displayMsg.message.model) || displayMsg.message.content.length > 0 && typeof displayMsg.message.metadata?.selected_model === "string"); $[34] = displayMsg; $[35] = isTranscriptMode; $[36] = t5; diff --git a/src/services/api/openaiShim.test.ts b/src/services/api/openaiShim.test.ts index 65533de442..a35aca350b 100644 --- a/src/services/api/openaiShim.test.ts +++ b/src/services/api/openaiShim.test.ts @@ -315,6 +315,38 @@ test('adds Verboo session header only for Verboo router requests', async () => { ) }) +test.each([false, true])('jev-router exposes the selected model in message metadata (stream=%s)', async stream => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = VERBOO_ROUTER_URL + globalThis.fetch = (async () => { + const headers = { 'X-Verboo-Selected-Model': 'glm-5.3-flash' } + if (stream) { + return makeSseResponse(makeStreamChunks([ + { choices: [{ delta: { content: 'ok' }, finish_reason: null }] }, + { choices: [{ delta: {}, finish_reason: 'stop' }] }, + ]), headers) + } + return new Response(JSON.stringify({ + id: 'chatcmpl-selected', + model: 'jev-router', + choices: [{ message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }], + }), { headers: { ...headers, 'Content-Type': 'application/json' } }) + }) as unknown as FetchType + + const client = createOpenAIShimClient({}) as OpenAIShimClient + const result = await client.beta.messages.create({ + model: 'jev-router', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 64, + stream, + }) as Record | AsyncIterable> + const message = stream + ? (await Array.fromAsync(result as AsyncIterable>))[0]?.message as Record + : result as Record + expect(message.model).toBe('jev-router') + expect(message.metadata).toEqual({ selected_model: 'glm-5.3-flash' }) +}) + test('captures router rate limit headers from successful Verboo router responses', async () => { process.env.CLAUDE_CODE_USE_OPENAI = '1' process.env.OPENAI_BASE_URL = VERBOO_ROUTER_URL diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index fdfd983efa..17395b26c4 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -130,6 +130,22 @@ const GITHUB_429_MAX_DELAY_SEC = 32 const GEMINI_API_HOST = 'generativelanguage.googleapis.com' const VERBOO_SESSION_HEADER = 'X-Verboo-Session-Id' const VERBOO_REQUEST_ID_HEADER = 'x-verboo-request-id' +const VERBOO_SELECTED_MODEL_HEADER = 'x-verboo-selected-model' + +function selectedModelMetadata( + response: Response, + requestedModel: string, + baseUrl: string, +): { selected_model: string } | undefined { + if (!isVerbooRouterUrl(baseUrl) || requestedModel.split('/').at(-1) !== 'jev-router') { + return undefined + } + const selected = response.headers.get(VERBOO_SELECTED_MODEL_HEADER) + if (!selected || selected.length > 128 || !/^[A-Za-z0-9._:/+-]+$/.test(selected)) { + return undefined + } + return { selected_model: selected } +} type ClientDiagnosticStage = | 'client_decode' @@ -1429,6 +1445,7 @@ function createWarmingHintController( async function* openaiStreamToAnthropic( response: Response, model: string, + routingMetadata?: { selected_model: string }, signal?: AbortSignal, warmingHint?: WarmingHintController, advertisedToolNames: readonly string[] = [], @@ -1484,6 +1501,7 @@ async function* openaiStreamToAnthropic( role: 'assistant', content: [], model, + ...(routingMetadata && { metadata: routingMetadata }), stop_reason: null, stop_sequence: null, usageReported: false, @@ -2762,6 +2780,16 @@ class OpenAIShimMessages { throw e } httpResponse = response + const routingMetadata = selectedModelMetadata( + response, + request.resolvedModel, + request.baseUrl, + ) + + const withRoutingMetadata = (message: T): T => + routingMetadata + ? { ...message, metadata: routingMetadata } + : message if (params.stream) { const isResponsesStream = response.url?.includes('/responses') @@ -2779,6 +2807,7 @@ class OpenAIShimMessages { : openaiStreamToAnthropic( response, request.resolvedModel, + routingMetadata, options?.signal, warmingHint, advertisedToolNames, @@ -2827,24 +2856,24 @@ class OpenAIShimMessages { recoverableToolNames, ) } - return self._convertNonStreamingResponse( + return withRoutingMetadata(self._convertNonStreamingResponse( parsed, request.resolvedModel, advertisedToolNames, recoverableToolNames, - ) + )) } } const contentType = response.headers.get('content-type') ?? '' if (contentType.includes('application/json')) { const data = await readSuccessfulResponseJson(response) - return self._convertNonStreamingResponse( + return withRoutingMetadata(self._convertNonStreamingResponse( data, request.resolvedModel, advertisedToolNames, recoverableToolNames, - ) + )) } const textBody = await readSuccessfulResponseText(response).catch(