diff --git a/config.yaml.example b/config.yaml.example index 47d2fda2..1e56ebbd 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -1,4 +1,9 @@ model: gpt-5.4 # LLM model (any LiteLLM-supported provider) +# Responses-only models (served exclusively on the provider's /v1/responses +# endpoint, e.g. some managed gateways): prefix with `openai-responses/`. +# OpenKB then talks the Responses API for compile/query/chat/lint/skills. +# Long-PDF PageIndex indexing still needs a Chat Completions model. +# model: openai-responses/ language: en # Wiki output language pageindex_threshold: 20 # PDF pages threshold for PageIndex diff --git a/examples/configuration/README.md b/examples/configuration/README.md index 25e5b3a8..3ef3967d 100644 --- a/examples/configuration/README.md +++ b/examples/configuration/README.md @@ -141,6 +141,27 @@ litellm: timeout: 1200 # raise further (e.g. 3600) for large local models ``` +#### Responses-only models (Responses API transport) + +Some gateways serve certain models exclusively on the `/v1/responses` +endpoint and answer `/v1/chat/completions` with `503 Endpoint is +unavailable`. Prefix the model with `openai-responses/` and OpenKB talks +the Responses API for every agent call (compile, query, chat, lint, +skills, decks) — including tool calls and `run_streamed` sessions: + +```yaml +model: openai-responses/ +language: en +``` + +The prefix is OpenKB-only (LiteLLM never sees it). Credentials work as +usual: `LLM_API_KEY` + `OPENAI_API_BASE` from `/.env`, plus any +`litellm.extra_headers` your gateway needs (e.g. a session-routing +header). Two limitations: long-PDF PageIndex indexing still requires a +Chat Completions model (`openkb add` fails fast with a clear error +otherwise — convert the PDF to Markdown first), and streaming is +supported through the Agents SDK path only. + #### GitHub Copilot / ChatGPT-subscription providers These need extra headers and use OAuth (no API key): diff --git a/openkb/agent/compiler.py b/openkb/agent/compiler.py index d0c9f878..fcff0469 100644 --- a/openkb/agent/compiler.py +++ b/openkb/agent/compiler.py @@ -38,6 +38,7 @@ ) from openkb.lint import list_existing_wiki_targets, strip_ghost_wikilinks from openkb.locks import atomic_write_text +from openkb.responses import aresponses_completion, is_responses_model, responses_completion from openkb.schema import INDEX_SEED, get_agents_md logger = logging.getLogger(__name__) @@ -425,7 +426,10 @@ def _llm_call( spinner.start() t0 = time.time() - response = litellm.completion(model=model, messages=messages, **kwargs) + if is_responses_model(model): + response = responses_completion(model=model, messages=messages, **kwargs) + else: + response = litellm.completion(model=model, messages=messages, **kwargs) content = response.choices[0].message.content or "" truncated = _warn_if_truncated(response, step_name, kwargs.get("max_tokens")) @@ -466,7 +470,10 @@ async def _llm_call_async( t0 = time.time() - response = await litellm.acompletion(model=model, messages=messages, **kwargs) + if is_responses_model(model): + response = await aresponses_completion(model=model, messages=messages, **kwargs) + else: + response = await litellm.acompletion(model=model, messages=messages, **kwargs) content = response.choices[0].message.content or "" truncated = _warn_if_truncated(response, step_name, kwargs.get("max_tokens")) diff --git a/openkb/agent/linter.py b/openkb/agent/linter.py index 60a96ced..5885bf42 100644 --- a/openkb/agent/linter.py +++ b/openkb/agent/linter.py @@ -9,6 +9,7 @@ from openkb.agent.tools import list_wiki_files, read_wiki_file from openkb.config import LlmCredentialBundle, resolve_model_settings +from openkb.responses import responses_agent_model from openkb.schema import get_agents_md MAX_TURNS = 50 @@ -97,7 +98,7 @@ def read_file(path: str) -> str: name="wiki-linter", instructions=instructions, tools=[list_files, read_file], - model=f"litellm/{model}", + model=(responses_agent_model(model, bundle) or f"litellm/{model}"), model_settings=ModelSettings(**model_settings), ) diff --git a/openkb/agent/query.py b/openkb/agent/query.py index da1a939e..9f85d468 100644 --- a/openkb/agent/query.py +++ b/openkb/agent/query.py @@ -15,6 +15,7 @@ write_kb_file, ) from openkb.config import LlmCredentialBundle, resolve_model_settings +from openkb.responses import responses_agent_model from openkb.schema import get_agents_md MAX_TURNS = 50 @@ -118,7 +119,7 @@ def get_image(image_path: str) -> ToolOutputImage | ToolOutputText: name="wiki-query", instructions=instructions, tools=[read_file, get_page_content, get_image], - model=f"litellm/{model}", + model=(responses_agent_model(model, bundle) or f"litellm/{model}"), model_settings=ModelSettings(**model_settings), ) @@ -506,9 +507,20 @@ def build_run_config_from_bundle(model: str, bundle: "LlmCredentialBundle | None must NOT be added here -- doing so yields ``litellm/openai/...`` which litellm rejects as an unknown provider. """ + from agents import RunConfig + if bundle is None: + instance = responses_agent_model(model, None) + if instance is not None: + # Legacy CLI path for Responses models: credentials fall back + # to LLM_API_KEY / OPENAI_API_BASE env (loaded from the KB + # .env by _setup_llm_key before this is called). + return RunConfig(model=instance) return None - from agents import RunConfig + + instance = responses_agent_model(model, bundle) + if instance is not None: + return RunConfig(model=instance) from agents.extensions.models.litellm_model import LitellmModel litellm_model = LitellmModel( diff --git a/openkb/indexer.py b/openkb/indexer.py index 6a4ae1ee..d70cadf4 100644 --- a/openkb/indexer.py +++ b/openkb/indexer.py @@ -12,6 +12,7 @@ from pageindex import IndexConfig, PageIndexClient from openkb.config import resolve_concurrency, resolve_effective_config +from openkb.responses import is_responses_model from openkb.tree_renderer import render_summary_md logger = logging.getLogger(__name__) @@ -191,6 +192,12 @@ def index_long_document(pdf_path: Path, kb_dir: Path, doc_name: str | None = Non config = resolve_effective_config(kb_dir)[0] model: str = config.get("model", "gpt-5.4") + if is_responses_model(model): + raise ValueError( + f"Long-PDF PageIndex indexing requires a Chat Completions model, " + f"but the KB is configured with Responses-only {model!r}. " + f"Convert the PDF to Markdown first, or switch the KB model." + ) pageindex_api_key = os.environ.get("PAGEINDEX_API_KEY", "") index_config = _build_index_config(config) diff --git a/openkb/responses.py b/openkb/responses.py new file mode 100644 index 00000000..233e300c --- /dev/null +++ b/openkb/responses.py @@ -0,0 +1,333 @@ +"""Native OpenAI Responses API transport for Responses-only models. + +Some gateways serve certain models exclusively on ``/v1/responses`` and +answer ``/v1/chat/completions`` with ``503 Endpoint is unavailable``. +Prefix the model id to opt in:: + + model: openai-responses/ + +The prefix is OpenKB-only (stripped before anything reaches LiteLLM). +Responses are adapted back to the Chat Completions shape, so existing +call sites — compile, query/chat/lint/skills agents, usage reporting — +work unchanged. Limitation: long-PDF PageIndex indexing still needs a +Chat Completions model (see :mod:`openkb.indexer` guard). +""" + +from __future__ import annotations + +import logging +import os +from types import SimpleNamespace +from typing import Any + +import litellm +from agents.models.openai_responses import OpenAIResponsesModel +from openai import AsyncOpenAI + +logger = logging.getLogger(__name__) + +RESPONSES_PREFIX = "openai-responses/" + +#: Chat Completions kwargs with no Responses API equivalent — dropped +#: (debug-logged) instead of failing the call. +_DROPPED_PARAMS = frozenset( + { + "stop", + "frequency_penalty", + "presence_penalty", + "seed", + "logprobs", + "top_logprobs", + "logit_bias", + "n", + "stream_options", + "service_tier", + "prompt_cache_key", + } +) + + +def is_responses_model(model: str) -> bool: + """Return True when *model* opts into the Responses API transport.""" + return model.startswith(RESPONSES_PREFIX) + + +def split_responses_model(model: str) -> tuple[str, str]: + """Split ``openai-responses/`` into (litellm_model, bare_id). + + ``litellm_model`` (``openai/``) is what LiteLLM's ``responses()`` + needs for provider routing; ``bare_id`` is what the Agents SDK and + the upstream endpoint expect as the model name. + """ + bare_id = model[len(RESPONSES_PREFIX) :] + return f"openai/{bare_id}", bare_id + + +def messages_to_responses_input(messages: list[dict]) -> list[dict]: + """Translate Chat Completions ``messages`` to Responses ``input`` items.""" + items: list[dict] = [] + for msg in messages: + role = msg.get("role") + if role == "tool": + items.append( + { + "type": "function_call_output", + "call_id": msg.get("tool_call_id", ""), + "output": _stringify_content(msg.get("content", "")), + } + ) + continue + is_assistant = role == "assistant" + if is_assistant and msg.get("tool_calls"): + content = _translate_content(msg.get("content"), for_assistant=True) + if content: + items.append({"role": "assistant", "content": content}) + for tc in msg["tool_calls"]: + fn = tc.get("function", {}) + items.append( + { + "type": "function_call", + "call_id": tc.get("id", ""), + "name": fn.get("name", ""), + "arguments": fn.get("arguments", "{}"), + } + ) + continue + content = _translate_content(msg.get("content"), for_assistant=is_assistant) + if content is None: + continue # e.g. reasoning-only placeholder, nothing to send + items.append({"role": role, "content": content}) + return items + + +def _translate_content(content: Any, *, for_assistant: bool = False) -> Any: + """Translate message content to Responses input shape (blocks mapped).""" + if isinstance(content, list): + return [_translate_content_block(b, for_assistant=for_assistant) for b in content] + return content + + +def _translate_content_block(block: Any, *, for_assistant: bool = False) -> Any: + """Map one content block to Responses input shape. + + ``output_text`` (not ``input_text``) is required inside ``assistant`` + messages; ``cache_control`` leftovers are stripped; unknown types + pass through. + """ + if not isinstance(block, dict): + return block + kind = block.get("type") + if kind == "text": + return { + "type": "output_text" if for_assistant else "input_text", + "text": block.get("text", ""), + } + if kind == "image_url": + image = block.get("image_url", {}) + if isinstance(image, str): + return {"type": "input_image", "image_url": image} + converted: dict[str, Any] = {"type": "input_image"} + if image.get("url") is not None: + converted["image_url"] = image["url"] + if image.get("detail") is not None: + converted["detail"] = image["detail"] + return converted + return {k: v for k, v in block.items() if k != "cache_control"} + + +def _stringify_content(content: Any) -> str: + if isinstance(content, str): + return content + if isinstance(content, list): + parts = [ + b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text" + ] + return "".join(parts) + return str(content) + + +def response_format_to_text(response_format: Any) -> dict | None: + """Map a Chat Completions ``response_format`` to Responses ``text``.""" + if not isinstance(response_format, dict): + return None + kind = response_format.get("type") + if kind == "json_object": + return {"format": {"type": "json_object"}} + if kind == "json_schema": + schema = response_format.get("json_schema", {}) + return { + "format": { + "type": "json_schema", + "name": schema.get("name", "response"), + "schema": schema.get("schema", {"type": "object"}), + "strict": schema.get("strict", False), + } + } + return None + + +def extract_output_text(response: Any) -> tuple[str, bool]: + """Pull assistant text out of a Responses API object. + + Returns ``(text, is_incomplete)``; ``is_incomplete`` mirrors the + Chat Completions ``finish_reason == "length"`` signal. + """ + output = getattr(response, "output", None) or [] + if isinstance(output, dict): + output = [output] + parts: list[str] = [] + for item in output: + as_dict = item if isinstance(item, dict) else _to_dict(item) + if as_dict.get("type") != "message": + continue + for block in as_dict.get("content", []) or []: + b = block if isinstance(block, dict) else _to_dict(block) + if b.get("type") == "output_text": + parts.append(b.get("text", "")) + status = getattr(response, "status", "completed") + return "".join(parts), status == "incomplete" + + +def _to_dict(obj: Any) -> dict: + if isinstance(obj, dict): + return obj + for attr in ("model_dump", "to_dict"): + fn = getattr(obj, attr, None) + if callable(fn): + try: + result = fn() + except Exception: # noqa: BLE001 -- try the next coercion + continue + if isinstance(result, dict): + return result + return getattr(obj, "__dict__", {}) + + +def adapt_response(response: Any) -> SimpleNamespace: + """Adapt a Responses object to the Chat Completions shape call sites read.""" + text, incomplete = extract_output_text(response) + usage = getattr(response, "usage", None) or SimpleNamespace() + adapted_usage = SimpleNamespace( + prompt_tokens=getattr(usage, "input_tokens", 0), + completion_tokens=getattr(usage, "output_tokens", 0), + total_tokens=getattr(usage, "total_tokens", 0), + prompt_tokens_details=None, + ) + return SimpleNamespace( + choices=[ + SimpleNamespace( + message=SimpleNamespace(content=text or ""), + finish_reason="length" if incomplete else "stop", + ) + ], + usage=adapted_usage, + ) + + +def build_responses_params(model: str, messages: list[dict], **kwargs: Any) -> tuple[str, dict]: + """Build ``(litellm_model, params)`` for :func:`litellm.aresponses`.""" + litellm_model, _ = split_responses_model(model) + params: dict[str, Any] = {"input": messages_to_responses_input(messages)} + for key, value in kwargs.items(): + if value is None: + continue + if key in _DROPPED_PARAMS: + logger.debug("Responses API: dropping unsupported param %r", key) + continue + if key in ("max_tokens", "max_completion_tokens", "max_output_tokens"): + params.setdefault("max_output_tokens", value) + elif key == "response_format": + text = response_format_to_text(value) + if text is not None: + params["text"] = text + elif key in ( + "temperature", + "top_p", + "tools", + "tool_choice", + "parallel_tool_calls", + "api_key", + "base_url", + "extra_headers", + "timeout", + "user", + "metadata", + ): + params[key] = value + else: + # Unknown but possibly supported (e.g. reasoning) — forward it + # and let the provider decide rather than failing here. + params[key] = value + return litellm_model, params + + +def responses_completion(model: str, messages: list[dict], **kwargs: Any) -> SimpleNamespace: + """Sync Chat-Completions-shaped call over the Responses API.""" + litellm_model, params = build_responses_params(model, messages, **kwargs) + return adapt_response(litellm.responses(model=litellm_model, **params)) + + +async def aresponses_completion(model: str, messages: list[dict], **kwargs: Any) -> SimpleNamespace: + """Async Chat-Completions-shaped call over the Responses API.""" + litellm_model, params = build_responses_params(model, messages, **kwargs) + return adapt_response(await litellm.aresponses(model=litellm_model, **params)) + + +def responses_agent_model(model: str, bundle: Any = None) -> Any: + """Return an Agents-SDK model instance for Responses models, else None. + + Callers use it as ``model=(responses_agent_model(model, bundle) or + )`` so Chat Completions behavior is untouched. + Credentials come from *bundle*, else ``LLM_API_KEY``/ + ``OPENAI_API_BASE`` env (legacy CLI path). + """ + if not is_responses_model(model): + return None + if bundle is not None: + return build_agents_responses_model( + model, + api_key=bundle.api_key, + base_url=bundle.base_url, + extra_headers=bundle.extra_headers, + timeout=bundle.timeout, + ) + return build_agents_responses_model(model) + + +def _env_credentials() -> tuple[str | None, str | None]: + """Fall back to process env for the bundle=None (legacy CLI) path.""" + api_key = ( + os.environ.get("LLM_API_KEY") + or os.environ.get("OPENAI_API_KEY") + or os.environ.get("OPENCODE_API_KEY") + or None + ) + return api_key, os.environ.get("OPENAI_API_BASE") + + +def build_agents_responses_model( + model: str, + *, + api_key: str | None = None, + base_url: str | None = None, + extra_headers: dict | None = None, + timeout: float | None = None, +) -> Any: + """Build an Agents-SDK ``OpenAIResponsesModel`` for a Responses model. + + Uses a dedicated ``AsyncOpenAI`` client so per-KB credentials never + touch process-global state. When credentials are omitted (legacy CLI + path), they fall back to ``LLM_API_KEY``/``OPENAI_API_BASE`` env vars. + """ + _, bare_id = split_responses_model(model) + if api_key is None or base_url is None: + env_key, env_base = _env_credentials() + api_key = api_key or env_key + base_url = base_url or env_base + client_kwargs: dict[str, Any] = {"api_key": api_key, "base_url": base_url} + if extra_headers: + client_kwargs["default_headers"] = dict(extra_headers) + if timeout is not None: + client_kwargs["timeout"] = timeout + client = AsyncOpenAI(**client_kwargs) + return OpenAIResponsesModel(model=bare_id, openai_client=client) diff --git a/openkb/skill/creator.py b/openkb/skill/creator.py index f9002417..fd746f28 100644 --- a/openkb/skill/creator.py +++ b/openkb/skill/creator.py @@ -22,6 +22,7 @@ from openkb.config import LlmCredentialBundle, resolve_model_settings from openkb.prompts import load_prompt +from openkb.responses import responses_agent_model from openkb.schema import get_agents_md from openkb.skill import skill_dir from openkb.skill.tools import ( @@ -179,7 +180,7 @@ def done(summary: str) -> str: write_skill_file, done, ], - model=f"litellm/{model}", + model=(responses_agent_model(model, bundle) or f"litellm/{model}"), model_settings=ModelSettings(**model_settings), ) diff --git a/openkb/skill/evaluator.py b/openkb/skill/evaluator.py index 8de6210b..60fe951e 100644 --- a/openkb/skill/evaluator.py +++ b/openkb/skill/evaluator.py @@ -47,6 +47,7 @@ from agents.model_settings import ModelSettings from openkb.config import get_extra_headers, get_timeout_extra_args +from openkb.responses import responses_agent_model from openkb.skill import extract_body, extract_frontmatter EVAL_DEFAULT_COUNT = 10 # 10 trigger + 10 no-trigger = 20 prompts @@ -234,7 +235,7 @@ async def generate_eval_set( agent = Agent( name="eval-set-generator", instructions=instructions, - model=f"litellm/{model}", + model=(responses_agent_model(model) or f"litellm/{model}"), model_settings=ModelSettings( extra_headers=get_extra_headers() or None, extra_args=get_timeout_extra_args(), @@ -296,7 +297,7 @@ async def grade_one( agent = Agent( name="trigger-grader", instructions=instructions, - model=f"litellm/{model}", + model=(responses_agent_model(model) or f"litellm/{model}"), model_settings=ModelSettings( extra_headers=get_extra_headers() or None, extra_args=get_timeout_extra_args(), @@ -348,7 +349,7 @@ async def grade_coverage( agent = Agent( name="coverage-grader", instructions=instructions, - model=f"litellm/{model}", + model=(responses_agent_model(model) or f"litellm/{model}"), model_settings=ModelSettings( extra_headers=get_extra_headers() or None, extra_args=get_timeout_extra_args(), diff --git a/tests/test_responses.py b/tests/test_responses.py new file mode 100644 index 00000000..7fbce759 --- /dev/null +++ b/tests/test_responses.py @@ -0,0 +1,232 @@ +"""Unit tests for openkb.responses (no network — pure translation logic).""" + +from types import SimpleNamespace + +from openkb.responses import ( + adapt_response, + build_responses_params, + extract_output_text, + is_responses_model, + messages_to_responses_input, + response_format_to_text, + split_responses_model, +) + + +def test_prefix_detection(): + assert is_responses_model("openai-responses/my-model") + assert not is_responses_model("openai/gpt-5.4") + assert not is_responses_model("gemini/gemini-2.5-flash") + + +def test_split(): + litellm_model, bare = split_responses_model("openai-responses/my-model") + assert litellm_model == "openai/my-model" + assert bare == "my-model" + + +def test_messages_passthrough(): + out = messages_to_responses_input( + [ + {"role": "system", "content": "sys"}, + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "hello"}, + ] + ) + assert out == [ + {"role": "system", "content": "sys"}, + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "hello"}, + ] + + +def test_messages_tool_round_trip(): + out = messages_to_responses_input( + [ + { + "role": "assistant", + "content": "calling", + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": {"name": "read", "arguments": '{"p": "x"}'}, + } + ], + }, + {"role": "tool", "tool_call_id": "call_1", "content": "file text"}, + ] + ) + assert {"role": "assistant", "content": "calling"} in out + assert { + "type": "function_call", + "call_id": "call_1", + "name": "read", + "arguments": '{"p": "x"}', + } in out + assert { + "type": "function_call_output", + "call_id": "call_1", + "output": "file text", + } in out + + +def test_messages_skip_none_content(): + assert messages_to_responses_input([{"role": "assistant", "content": None}]) == [] + + +def test_assistant_blocks_with_tool_calls_translated(): + out = messages_to_responses_input( + [ + { + "role": "assistant", + "content": [{"type": "text", "text": "calling"}], + "tool_calls": [ + { + "id": "c1", + "type": "function", + "function": {"name": "f", "arguments": "{}"}, + } + ], + } + ] + ) + assert { + "role": "assistant", + "content": [{"type": "output_text", "text": "calling"}], + } in out + assert {"type": "function_call", "call_id": "c1", "name": "f", "arguments": "{}"} in out + + +def test_content_block_translation(): + out = messages_to_responses_input( + [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "look", + "cache_control": {"type": "ephemeral"}, + }, + { + "type": "image_url", + "image_url": {"url": "data:image/png;base64,xx", "detail": "high"}, + }, + {"type": "mystery", "cache_control": {"type": "ephemeral"}, "x": 1}, + ], + }, + {"role": "assistant", "content": [{"type": "text", "text": "prior"}]}, + ] + ) + assert out == [ + { + "role": "user", + "content": [ + {"type": "input_text", "text": "look"}, + { + "type": "input_image", + "image_url": "data:image/png;base64,xx", + "detail": "high", + }, + {"type": "mystery", "x": 1}, + ], + }, + { + "role": "assistant", + "content": [{"type": "output_text", "text": "prior"}], + }, + ] + + +def test_build_agents_responses_model_wiring(): + from openkb.responses import build_agents_responses_model + + model = build_agents_responses_model( + "openai-responses/my-model", + api_key="k", + base_url="https://example.test/v1", + extra_headers={"x-opencode-session": "s"}, + timeout=120, + ) + assert model.model == "my-model" + client = model._client + assert str(client.base_url) == "https://example.test/v1/" + assert client._custom_headers.get("x-opencode-session") == "s" + + +def test_build_agents_responses_model_defaults_omit_timeout(): + import os + + from openkb.responses import build_agents_responses_model + + os.environ["LLM_API_KEY"] = "env-key" + os.environ["OPENAI_API_BASE"] = "https://env.test/v1" + try: + model = build_agents_responses_model("openai-responses/m") + finally: + del os.environ["LLM_API_KEY"] + del os.environ["OPENAI_API_BASE"] + assert str(model._client.base_url) == "https://env.test/v1/" + assert model._client.timeout is not None + + +def test_response_format_mapping(): + assert response_format_to_text({"type": "json_object"}) == {"format": {"type": "json_object"}} + mapped = response_format_to_text( + {"type": "json_schema", "json_schema": {"name": "r", "schema": {"type": "object"}}} + ) + assert mapped["format"]["type"] == "json_schema" + assert mapped["format"]["name"] == "r" + assert response_format_to_text({"type": "text"}) is None + assert response_format_to_text(None) is None + + +def _fake_responses_output(text="done", incomplete=False): + return SimpleNamespace( + output=[ + {"type": "message", "content": [{"type": "output_text", "text": text}]}, + {"type": "reasoning", "summary": []}, + ], + status="incomplete" if incomplete else "completed", + usage=SimpleNamespace(input_tokens=10, output_tokens=5, total_tokens=15), + ) + + +def test_extract_text(): + text, incomplete = extract_output_text(_fake_responses_output("hello")) + assert text == "hello" + assert incomplete is False + _, incomplete = extract_output_text(_fake_responses_output(incomplete=True)) + assert incomplete is True + + +def test_adapt_response_shape(): + adapted = adapt_response(_fake_responses_output("hi")) + assert adapted.choices[0].message.content == "hi" + assert adapted.choices[0].finish_reason == "stop" + assert adapted.usage.prompt_tokens == 10 + assert adapted.usage.completion_tokens == 5 + adapted = adapt_response(_fake_responses_output(incomplete=True)) + assert adapted.choices[0].finish_reason == "length" + + +def test_build_params_mapping(): + model, params = build_responses_params( + "openai-responses/my-model", + [{"role": "user", "content": "hi"}], + max_tokens=100, + response_format={"type": "json_object"}, + temperature=0.5, + stop=["x"], + api_key="k", + base_url="b", + ) + assert model == "openai/my-model" + assert params["max_output_tokens"] == 100 + assert params["text"] == {"format": {"type": "json_object"}} + assert params["temperature"] == 0.5 + assert "stop" not in params # dropped, unsupported + assert params["api_key"] == "k" + assert params["base_url"] == "b" + assert params["input"] == [{"role": "user", "content": "hi"}]