From 7cabcb06e58e155ece3972c613c23ee8494380c9 Mon Sep 17 00:00:00 2001 From: Daryl Date: Mon, 21 Sep 2026 11:26:35 -0500 Subject: [PATCH 1/3] Add native OpenAI Responses API transport (openai-responses/ model prefix) Some gateways serve models exclusively on /v1/responses and 503 /v1/chat/completions. A KB can now opt in with e.g. model: openai-responses/muse-spark-1.3-contributor. - New openkb/responses.py: message/tool/response_format translation, Chat-Completions-shaped adapter, Agents-SDK OpenAIResponsesModel builder - compiler: route _llm_call/_llm_call_async through litellm.responses - query/chat/lint/skills/decks: Agent constructors + run-config builder use the Responses model (creds from bundle, else env) - indexer: clear error for long-PDF PageIndex path (needs completions) - Docs + 12 unit tests --- config.yaml.example | 5 + examples/configuration/README.md | 21 ++ openkb/agent/compiler.py | 11 +- openkb/agent/linter.py | 3 +- openkb/agent/query.py | 16 +- openkb/indexer.py | 7 + openkb/responses.py | 359 +++++++++++++++++++++++++++++++ openkb/skill/creator.py | 3 +- openkb/skill/evaluator.py | 7 +- tests/test_responses.py | 209 ++++++++++++++++++ 10 files changed, 632 insertions(+), 9 deletions(-) create mode 100644 openkb/responses.py create mode 100644 tests/test_responses.py diff --git a/config.yaml.example b/config.yaml.example index 47d2fda2..6a87f74c 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/muse-spark-1.3-contributor 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..01522043 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/muse-spark-1.3-contributor +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..bfc5d995 --- /dev/null +++ b/openkb/responses.py @@ -0,0 +1,359 @@ +"""Native OpenAI Responses API transport for Responses-only models. + +Some gateways serve certain models exclusively on the ``/v1/responses`` +endpoint and answer ``/v1/chat/completions`` with ``503 Endpoint is +unavailable``. OpenKB talks to LLMs through LiteLLM Chat Completions, so +those models are unreachable — unless the KB opts in here by prefixing +the model id:: + + model: openai-responses/muse-spark-1.3-contributor + +The prefix is an OpenKB-only convention (LiteLLM never sees it). It is +recognized at the two places a model string is consumed: + +- :func:`openkb.agent.compiler._llm_call` / ``_llm_call_async`` — routed + to :func:`litellm.responses` / :func:`litellm.aresponses`, with the + result adapted back to the Chat Completions shape the compiler reads. +- :func:`openkb.agent.query.build_run_config_from_bundle` — builds an + agents-SDK ``OpenAIResponsesModel`` over a custom ``AsyncOpenAI`` + client (per-KB ``api_key`` / ``base_url`` / ``extra_headers``), so + query/chat/lint/skills — including ``run_streamed`` — work unchanged. + +Only ``openai-responses/`` (OpenAI-compatible bases) is supported. +Long-PDF PageIndex indexing still requires a Chat Completions model +(see :mod:`openkb.indexer` guard); short documents, query, chat, lint, +skills, and decks all work. +""" + +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. + + Plain user/assistant/system/developer messages pass through. Assistant + messages carrying ``tool_calls`` become ``function_call`` items (plus a + message item when they also have text); ``tool`` messages become + ``function_call_output`` items. Content blocks (``type: text`` / + ``image_url``) are preserved as-is. + """ + 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 + if role == "assistant" and msg.get("tool_calls"): + if msg.get("content"): + items.append({"role": "assistant", "content": msg["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 = msg.get("content") + if content is None: + continue # e.g. reasoning-only placeholder, nothing to send + if isinstance(content, list): + content = [ + _translate_content_block(b, for_assistant=(role == "assistant")) for b in content + ] + items.append({"role": role, "content": content}) + return items + + +def _translate_content_block(block: Any, *, for_assistant: bool = False) -> Any: + """Map one Chat Completions content block to Responses input shape. + + ``text`` -> ``input_text`` (or ``output_text`` inside ``assistant`` + messages — the endpoint rejects ``input_text`` there), ``image_url`` + -> ``input_image`` (lifting ``url``/``detail``); any residual provider + markers such as ``cache_control`` are stripped. Unknown block types + pass through untouched rather than being silently dropped. + """ + 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: + return fn() + except Exception: # noqa: BLE001 + pass + return getattr(obj, "__dict__", {}) + + +def adapt_response(response: Any) -> SimpleNamespace: + """Adapt a Responses API object to the Chat Completions shape. + + The compiler reads ``response.choices[0].message.content``, + ``response.choices[0].finish_reason``, and + ``response.usage.{prompt_tokens,completion_tokens}`` — this adapter + provides exactly that surface so call sites need no further changes. + """ + 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`. + + ``max_tokens``/``max_completion_tokens`` become ``max_output_tokens``, + ``response_format`` becomes ``text``, unsupported params are dropped + with a debug log. ``api_key``/``base_url``/``extra_headers``/``timeout`` + pass straight through to LiteLLM. + """ + 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..6a6c718a --- /dev/null +++ b/tests/test_responses.py @@ -0,0 +1,209 @@ +"""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/muse-spark-1.3-contributor") + 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/muse-spark-1.3-contributor") + assert litellm_model == "openai/muse-spark-1.3-contributor" + assert bare == "muse-spark-1.3-contributor" + + +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_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/muse-spark-1.3-contributor", + api_key="k", + base_url="https://example.test/v1", + extra_headers={"x-opencode-session": "s"}, + timeout=120, + ) + assert model.model == "muse-spark-1.3-contributor" + 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/muse-spark-1.3-contributor", + [{"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/muse-spark-1.3-contributor" + 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"}] From 038bdcc70648ac44cc92d8d670e42421fd111de8 Mon Sep 17 00:00:00 2001 From: Daryl Date: Mon, 21 Sep 2026 11:42:03 -0500 Subject: [PATCH 2/3] Review fixes: lean comments, shared content translator, explicit fallthrough - Trim module/function docstrings to the why; fix stale blocks claim - Extract _translate_content shared by both message paths (also fixes untranslated blocks on assistant+tool_calls messages) - _to_dict: explicit continue on failed coercion - Top-level third-party imports (no cycle); SDK wiring test --- openkb/responses.py | 92 +++++++++++++++-------------------------- tests/test_responses.py | 23 +++++++++++ 2 files changed, 56 insertions(+), 59 deletions(-) diff --git a/openkb/responses.py b/openkb/responses.py index bfc5d995..5fd03d83 100644 --- a/openkb/responses.py +++ b/openkb/responses.py @@ -1,28 +1,16 @@ """Native OpenAI Responses API transport for Responses-only models. -Some gateways serve certain models exclusively on the ``/v1/responses`` -endpoint and answer ``/v1/chat/completions`` with ``503 Endpoint is -unavailable``. OpenKB talks to LLMs through LiteLLM Chat Completions, so -those models are unreachable — unless the KB opts in here by prefixing -the model id:: +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/muse-spark-1.3-contributor -The prefix is an OpenKB-only convention (LiteLLM never sees it). It is -recognized at the two places a model string is consumed: - -- :func:`openkb.agent.compiler._llm_call` / ``_llm_call_async`` — routed - to :func:`litellm.responses` / :func:`litellm.aresponses`, with the - result adapted back to the Chat Completions shape the compiler reads. -- :func:`openkb.agent.query.build_run_config_from_bundle` — builds an - agents-SDK ``OpenAIResponsesModel`` over a custom ``AsyncOpenAI`` - client (per-KB ``api_key`` / ``base_url`` / ``extra_headers``), so - query/chat/lint/skills — including ``run_streamed`` — work unchanged. - -Only ``openai-responses/`` (OpenAI-compatible bases) is supported. -Long-PDF PageIndex indexing still requires a Chat Completions model -(see :mod:`openkb.indexer` guard); short documents, query, chat, lint, -skills, and decks all work. +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 @@ -76,14 +64,7 @@ def split_responses_model(model: str) -> tuple[str, str]: def messages_to_responses_input(messages: list[dict]) -> list[dict]: - """Translate Chat Completions ``messages`` to Responses ``input`` items. - - Plain user/assistant/system/developer messages pass through. Assistant - messages carrying ``tool_calls`` become ``function_call`` items (plus a - message item when they also have text); ``tool`` messages become - ``function_call_output`` items. Content blocks (``type: text`` / - ``image_url``) are preserved as-is. - """ + """Translate Chat Completions ``messages`` to Responses ``input`` items.""" items: list[dict] = [] for msg in messages: role = msg.get("role") @@ -96,9 +77,11 @@ def messages_to_responses_input(messages: list[dict]) -> list[dict]: } ) continue - if role == "assistant" and msg.get("tool_calls"): - if msg.get("content"): - items.append({"role": "assistant", "content": msg["content"]}) + 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( @@ -110,25 +93,26 @@ def messages_to_responses_input(messages: list[dict]) -> list[dict]: } ) continue - content = msg.get("content") + content = _translate_content(msg.get("content"), for_assistant=is_assistant) if content is None: continue # e.g. reasoning-only placeholder, nothing to send - if isinstance(content, list): - content = [ - _translate_content_block(b, for_assistant=(role == "assistant")) for b in content - ] 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 Chat Completions content block to Responses input shape. + """Map one content block to Responses input shape. - ``text`` -> ``input_text`` (or ``output_text`` inside ``assistant`` - messages — the endpoint rejects ``input_text`` there), ``image_url`` - -> ``input_image`` (lifting ``url``/``detail``); any residual provider - markers such as ``cache_control`` are stripped. Unknown block types - pass through untouched rather than being silently dropped. + ``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 @@ -211,20 +195,16 @@ def _to_dict(obj: Any) -> dict: fn = getattr(obj, attr, None) if callable(fn): try: - return fn() - except Exception: # noqa: BLE001 - pass + 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 API object to the Chat Completions shape. - - The compiler reads ``response.choices[0].message.content``, - ``response.choices[0].finish_reason``, and - ``response.usage.{prompt_tokens,completion_tokens}`` — this adapter - provides exactly that surface so call sites need no further changes. - """ + """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( @@ -245,13 +225,7 @@ def adapt_response(response: Any) -> SimpleNamespace: def build_responses_params(model: str, messages: list[dict], **kwargs: Any) -> tuple[str, dict]: - """Build ``(litellm_model, params)`` for :func:`litellm.aresponses`. - - ``max_tokens``/``max_completion_tokens`` become ``max_output_tokens``, - ``response_format`` becomes ``text``, unsupported params are dropped - with a debug log. ``api_key``/``base_url``/``extra_headers``/``timeout`` - pass straight through to LiteLLM. - """ + """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(): diff --git a/tests/test_responses.py b/tests/test_responses.py index 6a6c718a..609e0e80 100644 --- a/tests/test_responses.py +++ b/tests/test_responses.py @@ -75,6 +75,29 @@ 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( [ From c974b13bcbcc0b62256cd96f3fafe4eba556d004 Mon Sep 17 00:00:00 2001 From: Daryl Date: Mon, 21 Sep 2026 11:43:24 -0500 Subject: [PATCH 3/3] Generalize docs/examples: no model-specific references The openai-responses/ prefix works with any model id on any OpenAI-compatible Responses endpoint; examples now use neutral placeholders instead of a specific model. --- config.yaml.example | 2 +- examples/configuration/README.md | 2 +- openkb/responses.py | 2 +- tests/test_responses.py | 16 ++++++++-------- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/config.yaml.example b/config.yaml.example index 6a87f74c..1e56ebbd 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -3,7 +3,7 @@ model: gpt-5.4 # LLM model (any LiteLLM-supported provider) # 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/muse-spark-1.3-contributor +# 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 01522043..3ef3967d 100644 --- a/examples/configuration/README.md +++ b/examples/configuration/README.md @@ -150,7 +150,7 @@ the Responses API for every agent call (compile, query, chat, lint, skills, decks) — including tool calls and `run_streamed` sessions: ```yaml -model: openai-responses/muse-spark-1.3-contributor +model: openai-responses/ language: en ``` diff --git a/openkb/responses.py b/openkb/responses.py index 5fd03d83..233e300c 100644 --- a/openkb/responses.py +++ b/openkb/responses.py @@ -4,7 +4,7 @@ answer ``/v1/chat/completions`` with ``503 Endpoint is unavailable``. Prefix the model id to opt in:: - model: openai-responses/muse-spark-1.3-contributor + model: openai-responses/ The prefix is OpenKB-only (stripped before anything reaches LiteLLM). Responses are adapted back to the Chat Completions shape, so existing diff --git a/tests/test_responses.py b/tests/test_responses.py index 609e0e80..7fbce759 100644 --- a/tests/test_responses.py +++ b/tests/test_responses.py @@ -14,15 +14,15 @@ def test_prefix_detection(): - assert is_responses_model("openai-responses/muse-spark-1.3-contributor") + 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/muse-spark-1.3-contributor") - assert litellm_model == "openai/muse-spark-1.3-contributor" - assert bare == "muse-spark-1.3-contributor" + litellm_model, bare = split_responses_model("openai-responses/my-model") + assert litellm_model == "openai/my-model" + assert bare == "my-model" def test_messages_passthrough(): @@ -143,13 +143,13 @@ def test_build_agents_responses_model_wiring(): from openkb.responses import build_agents_responses_model model = build_agents_responses_model( - "openai-responses/muse-spark-1.3-contributor", + "openai-responses/my-model", api_key="k", base_url="https://example.test/v1", extra_headers={"x-opencode-session": "s"}, timeout=120, ) - assert model.model == "muse-spark-1.3-contributor" + 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" @@ -213,7 +213,7 @@ def test_adapt_response_shape(): def test_build_params_mapping(): model, params = build_responses_params( - "openai-responses/muse-spark-1.3-contributor", + "openai-responses/my-model", [{"role": "user", "content": "hi"}], max_tokens=100, response_format={"type": "json_object"}, @@ -222,7 +222,7 @@ def test_build_params_mapping(): api_key="k", base_url="b", ) - assert model == "openai/muse-spark-1.3-contributor" + assert model == "openai/my-model" assert params["max_output_tokens"] == 100 assert params["text"] == {"format": {"type": "json_object"}} assert params["temperature"] == 0.5