ocai-openai 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,60 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+ build/
7
+ dist/
8
+ *.so
9
+
10
+ # uv / venv
11
+ .venv/
12
+ uv.lock.bak
13
+
14
+ # Tooling caches
15
+ .pytest_cache/
16
+ .ruff_cache/
17
+ .mypy_cache/
18
+ .import_linter_cache/
19
+ .coverage
20
+ htmlcov/
21
+
22
+ # Docs site build output and page cache (zensical build)
23
+ /site/
24
+ /.cache/
25
+
26
+ # Node (future typescript/)
27
+ node_modules/
28
+ *.tsbuildinfo
29
+
30
+ # Go (future go/)
31
+ /go/bin/
32
+
33
+ # Editors / OS. Shared editor settings ARE committed: the multi-root workspace
34
+ # (ocai.code-workspace) and each folder's .vscode/settings.json + extensions.json
35
+ # give Pylance/Ruff the right interpreter per project. Personal files stay local.
36
+ .idea/
37
+ .vscode/*
38
+ !.vscode/settings.json
39
+ !.vscode/extensions.json
40
+ .DS_Store
41
+
42
+ # Per-user agent settings (never commit personal permissions)
43
+ .claude/settings.local.json
44
+ .codex/config.toml
45
+
46
+ # Platform build and browser-test artifacts
47
+ .nuxt/
48
+ .output/
49
+ platform/test-results/
50
+ platform/playwright-report/
51
+
52
+ # Local environment files: the root .env is the active one, .envs/ holds the
53
+ # per-environment variants (`task env -- <ENV>` copies .envs/.env.<ENV> to .env).
54
+ # Never committed: they hold credentials.
55
+ /.env
56
+ /.envs/
57
+ .local/
58
+
59
+ # Task execution fingerprints
60
+ .task/
@@ -0,0 +1,28 @@
1
+ # Changelog — ocai-openai
2
+
3
+ All notable changes to the `ocai-openai` distribution. Generated from the change files in
4
+ `.changeset/` by the release workflow (ADR 0010); do not edit released sections by hand.
5
+ ## 0.1.1 (2026-11-11)
6
+
7
+ ### Features
8
+
9
+ #### Admit provider-executed server-tool definitions with honest support reports
10
+
11
+ Admit provider-executed server-tool definitions with honest support reports. (WU-41, #80)
12
+
13
+ #### Preserve scoped request and replay anchors
14
+
15
+ Preserve scoped request and replay anchors. (WU-42, #83)
16
+
17
+ #### Qualify independent DeepSeek unary conversion
18
+
19
+ Share compatible mapping through explicit variant configuration while preserving
20
+ DeepSeek identity, mandatory reasoning replay, honest parameter effects and
21
+ measured usage. Add independently censused request encode/audit and response
22
+ decode edges with recoverable unknown-field evidence. (WU-44b, #91)
23
+
24
+ ### Fixes
25
+
26
+ #### Package metadata points at the real repository and the DeepSeek plugin has an extra
27
+
28
+ Homepage, Source, Documentation and Changelog URLs now point at `github.com/ocarinow/ocai` and the docs site. `ocai[deepseek]` installs the DeepSeek plugin and `ocai[all]` includes it. No runtime behavior changed.
@@ -0,0 +1,71 @@
1
+ Metadata-Version: 2.5
2
+ Name: ocai-openai
3
+ Version: 0.1.1
4
+ Summary: OpenAI adapter plugin for ocai (ships ocai.openai into the shared namespace).
5
+ Project-URL: Homepage, https://github.com/ocarinow/ocai
6
+ Project-URL: Source, https://github.com/ocarinow/ocai
7
+ Project-URL: Documentation, https://ocarinow.github.io/ocai/
8
+ Project-URL: Changelog, https://github.com/ocarinow/ocai/blob/main/python/packages/ocai-openai/CHANGELOG.md
9
+ Author: ocai maintainers
10
+ License-Expression: FSL-1.1-ALv2
11
+ Requires-Python: >=3.13
12
+ Requires-Dist: msgspec>=0.18
13
+ Requires-Dist: ocai<0.2,>=0.1
14
+ Description-Content-Type: text/markdown
15
+
16
+ # `ocai-openai`
17
+
18
+ The OpenAI **plugin distribution** for [ocai](https://github.com/ocarinow/ocai).
19
+ Distribution name: **`ocai-openai`**; `pip install ocai[openai]` resolves to the
20
+ same wheel. Requires Python 3.13+ and `ocai>=0.1,<0.2`.
21
+
22
+ It ships only `ocai/openai/` into the shared `ocai` PEP 420 namespace,
23
+ organized one subpackage per wire interface:
24
+
25
+ - `ocai.openai.chat_completions` — the `/v1/chat/completions` interface:
26
+ typed wire structs, the codec, contract-censused conversion edges, the
27
+ capability profile, and the chat component. Unary and streaming calls are
28
+ implemented and certified against recorded fixtures.
29
+ - `ocai.openai.responses` — the `/v1/responses` interface. A **stub**: its
30
+ nodes are declared so the interface exists in the graph, but it has no
31
+ edges, codec, or chat component yet. The front door never auto-selects it,
32
+ and pinning `interface="responses"` raises `LookupError` until it lands.
33
+
34
+ The two are sibling interfaces of the one chat capability, not versions of
35
+ each other.
36
+
37
+ ## What installing it does
38
+
39
+ Exactly one structural thing: the frozen `ocai.openai.FRAGMENT`
40
+ (a `CatalogFragment`) becomes discoverable through the `ocai.fragments`
41
+ entry-point group, so `ocai.chat.model("openai/<model>", ...)` can resolve the
42
+ provider and the conversion graph gains OpenAI's nodes and edges. Importing
43
+ `ocai.openai` is inert; there is no import-time registration. Applications
44
+ that want an explicit catalog compose it themselves with
45
+ `Catalog.of(ocai.openai.FRAGMENT)`.
46
+
47
+ ```python
48
+ from ocai.openai import chat_model
49
+ from ocai.core import ApiKey
50
+
51
+ model = chat_model("gpt-4o", credentials=ApiKey.from_env("OPENAI_API_KEY"))
52
+ result = model.chat(...) # needs ocai[http] and a real key
53
+ ```
54
+
55
+ `chat_model` is sugar over `ocai.chat.model("openai/<model>", ...)` on a
56
+ catalog built from this fragment, never a separate code path.
57
+
58
+ ## Boundaries
59
+
60
+ - No `openai` SDK: ocai owns the wire through `ocai.wire`. This plugin depends
61
+ on `ocai.core` + `ocai.convert` + `ocai.chat` only, never on `ocai.wire` or
62
+ `httpx`, so archived traffic can be re-converted by tooling with no ability
63
+ to make network calls.
64
+ - `msgspec` is used only at the wire-decode boundary.
65
+ - Every conversion edge declares an `EdgeContract` and ships golden fixtures
66
+ in the repository's `spec/`.
67
+
68
+ ## License
69
+
70
+ Source-available under FSL-1.1-ALv2 (converting to Apache-2.0 two years after
71
+ each release). ocai is not open source.
@@ -0,0 +1,56 @@
1
+ # `ocai-openai`
2
+
3
+ The OpenAI **plugin distribution** for [ocai](https://github.com/ocarinow/ocai).
4
+ Distribution name: **`ocai-openai`**; `pip install ocai[openai]` resolves to the
5
+ same wheel. Requires Python 3.13+ and `ocai>=0.1,<0.2`.
6
+
7
+ It ships only `ocai/openai/` into the shared `ocai` PEP 420 namespace,
8
+ organized one subpackage per wire interface:
9
+
10
+ - `ocai.openai.chat_completions` — the `/v1/chat/completions` interface:
11
+ typed wire structs, the codec, contract-censused conversion edges, the
12
+ capability profile, and the chat component. Unary and streaming calls are
13
+ implemented and certified against recorded fixtures.
14
+ - `ocai.openai.responses` — the `/v1/responses` interface. A **stub**: its
15
+ nodes are declared so the interface exists in the graph, but it has no
16
+ edges, codec, or chat component yet. The front door never auto-selects it,
17
+ and pinning `interface="responses"` raises `LookupError` until it lands.
18
+
19
+ The two are sibling interfaces of the one chat capability, not versions of
20
+ each other.
21
+
22
+ ## What installing it does
23
+
24
+ Exactly one structural thing: the frozen `ocai.openai.FRAGMENT`
25
+ (a `CatalogFragment`) becomes discoverable through the `ocai.fragments`
26
+ entry-point group, so `ocai.chat.model("openai/<model>", ...)` can resolve the
27
+ provider and the conversion graph gains OpenAI's nodes and edges. Importing
28
+ `ocai.openai` is inert; there is no import-time registration. Applications
29
+ that want an explicit catalog compose it themselves with
30
+ `Catalog.of(ocai.openai.FRAGMENT)`.
31
+
32
+ ```python
33
+ from ocai.openai import chat_model
34
+ from ocai.core import ApiKey
35
+
36
+ model = chat_model("gpt-4o", credentials=ApiKey.from_env("OPENAI_API_KEY"))
37
+ result = model.chat(...) # needs ocai[http] and a real key
38
+ ```
39
+
40
+ `chat_model` is sugar over `ocai.chat.model("openai/<model>", ...)` on a
41
+ catalog built from this fragment, never a separate code path.
42
+
43
+ ## Boundaries
44
+
45
+ - No `openai` SDK: ocai owns the wire through `ocai.wire`. This plugin depends
46
+ on `ocai.core` + `ocai.convert` + `ocai.chat` only, never on `ocai.wire` or
47
+ `httpx`, so archived traffic can be re-converted by tooling with no ability
48
+ to make network calls.
49
+ - `msgspec` is used only at the wire-decode boundary.
50
+ - Every conversion edge declares an `EdgeContract` and ships golden fixtures
51
+ in the repository's `spec/`.
52
+
53
+ ## License
54
+
55
+ Source-available under FSL-1.1-ALv2 (converting to Apache-2.0 two years after
56
+ each release). ocai is not open source.
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "ocai-openai"
7
+ version = "0.1.1"
8
+ description = "OpenAI adapter plugin for ocai (ships ocai.openai into the shared namespace)."
9
+ readme = "README.md"
10
+ requires-python = ">=3.13"
11
+ license = "FSL-1.1-ALv2" # source-available (FSL → Apache-2.0 after 2 years)
12
+ authors = [{ name = "ocai maintainers" }]
13
+ # Pin a COMPATIBLE CORE RANGE: every wheel merges into one runtime namespace, so
14
+ # a plugin must match the core public-API (semver) it was built against. The
15
+ # plugin depends on core+convert ONLY — never on ocai.wire or httpx, and it
16
+ # imports NO openai SDK (rule 12). msgspec is the sanctioned decode-boundary
17
+ # tool (rule 11): it decodes the OpenAI wire into typed dialect structs. It
18
+ # stays in the PLUGIN — never in ocai.core.
19
+ dependencies = ["ocai>=0.1,<0.2", "msgspec>=0.18"]
20
+
21
+ # The fragment entry point (`ocai.convert.catalog.ENTRY_POINT_GROUP`): names
22
+ # DATA — the frozen CatalogFragment — never a register() callable that would
23
+ # mutate globals at import time. `Catalog.discover()` composes it opt-in.
24
+ [project.entry-points."ocai.fragments"]
25
+ openai = "ocai.openai:FRAGMENT"
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/ocarinow/ocai"
29
+ Source = "https://github.com/ocarinow/ocai"
30
+ Documentation = "https://ocarinow.github.io/ocai/"
31
+ Changelog = "https://github.com/ocarinow/ocai/blob/main/python/packages/ocai-openai/CHANGELOG.md"
32
+
33
+ [tool.hatch.build.targets.wheel]
34
+ # Ship ONLY ocai/openai/ into the shared `ocai` namespace. Again: no
35
+ # src/ocai/__init__.py (PEP 420).
36
+ packages = ["src/ocai"]
37
+
38
+ [tool.hatch.build]
39
+ dev-mode-dirs = ["src"]
@@ -0,0 +1,53 @@
1
+ """`ocai.openai` — the OpenAI plugin distribution (`ocai-openai`).
2
+
3
+ Ships in the same `ocai` PEP 420 namespace as the core distribution but as a
4
+ separate dist, organized **one subpackage per interface** (§7):
5
+
6
+ - `ocai.openai.chat_completions` — the `/v1/chat/completions` wire protocol,
7
+ - `ocai.openai.responses` — the `/v1/responses` wire protocol [stub],
8
+
9
+ which are *concurrent sibling interfaces* of the one chat capability, not
10
+ versions of each other. Each subpackage carries its own wire models, codec,
11
+ contract-censused edges, and per-interface `FRAGMENT`.
12
+
13
+ This module composes them into the distribution-level `FRAGMENT` an
14
+ application opts into — `Catalog.of(ocai.openai.FRAGMENT)` — with **no
15
+ import-time registration** anywhere (rule 14; the old process-global
16
+ `register()` side effect is gone). Importing this package is inert. The
17
+ plugin depends on `ocai.core` + `ocai.convert` + `ocai.chat` only — never on
18
+ `ocai.wire` or httpx — so archived traffic can be re-converted by tooling
19
+ with no ability to make network calls.
20
+
21
+ It also re-exports `chat_model`, the sanctioned §3.4 sugar door
22
+ (`from ocai.openai import chat_model; chat_model("gpt-5", credentials=key)`)
23
+ — a thin wrapper over the same fragment + facade machinery as the core
24
+ string door, targeting the `chat_completions` interface today.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from ocai.convert.catalog import CatalogFragment
30
+ from ocai.openai import chat_completions
31
+ from ocai.openai import responses
32
+ from ocai.openai.chat_completions import chat_model
33
+ from ocai.openai.chat_completions.edges import ORIGIN
34
+
35
+ __all__ = [
36
+ "FRAGMENT",
37
+ "chat_completions",
38
+ "chat_model",
39
+ "responses",
40
+ ]
41
+
42
+ # The composed distribution fragment: everything both interfaces publish,
43
+ # under one provenance stamp. Compose the per-interface fragments instead
44
+ # when an application wants only one wire protocol in its catalog.
45
+ FRAGMENT = CatalogFragment(
46
+ origin=ORIGIN,
47
+ nodes=chat_completions.FRAGMENT.nodes + responses.FRAGMENT.nodes,
48
+ edges=chat_completions.FRAGMENT.edges + responses.FRAGMENT.edges,
49
+ transducers=chat_completions.FRAGMENT.transducers + responses.FRAGMENT.transducers,
50
+ profiles=chat_completions.FRAGMENT.profiles + responses.FRAGMENT.profiles,
51
+ traits=chat_completions.FRAGMENT.traits + responses.FRAGMENT.traits,
52
+ components=chat_completions.FRAGMENT.components + responses.FRAGMENT.components,
53
+ )
@@ -0,0 +1,110 @@
1
+ """`ocai.openai.chat_completions` — the OpenAI Chat Completions interface.
2
+
3
+ One subpackage per interface (§7): this package is everything ocai knows about
4
+ `POST /v1/chat/completions` as a wire protocol — its msgspec decode-boundary
5
+ models (`wire`), its identity + mapping codec (`codec`), and its declarative
6
+ catalog material (`edges`). The sibling `ocai.openai.responses` package is a
7
+ *concurrent interface* of the same chat capability, not a version of this one.
8
+
9
+ The public surface is `FRAGMENT`: a frozen, provenance-stamped
10
+ `CatalogFragment` an application composes explicitly —
11
+ `Catalog.of(ocai.openai.chat_completions.FRAGMENT, ...)` — with **no
12
+ import-time registration** into any global (rule 14). This package depends on
13
+ `ocai.core` + `ocai.convert` + `ocai.chat` only, never on `ocai.wire`/httpx,
14
+ so offline audit tooling can re-convert archived traffic without the ability
15
+ to make a network call.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from typing import TYPE_CHECKING
21
+
22
+ from ocai.convert.catalog import Catalog
23
+ from ocai.convert.catalog import CatalogFragment
24
+ from ocai.convert.catalog import Component
25
+ from ocai.core.identity import CHAT
26
+ from ocai.openai.chat_completions.codec import CHAT_COMPLETIONS_CHUNK
27
+ from ocai.openai.chat_completions.codec import CHAT_COMPLETIONS_REQUEST
28
+ from ocai.openai.chat_completions.codec import CHAT_COMPLETIONS_RESPONSE
29
+ from ocai.openai.chat_completions.codec import ChatCompletionsCodec
30
+ from ocai.openai.chat_completions.codec import dialect_traits
31
+ from ocai.openai.chat_completions.edges import EDGES
32
+ from ocai.openai.chat_completions.edges import NODES
33
+ from ocai.openai.chat_completions.edges import ORIGIN
34
+ from ocai.openai.chat_completions.edges import PROFILES
35
+ from ocai.openai.chat_completions.edges import TRANSDUCERS
36
+
37
+ if TYPE_CHECKING:
38
+ from ocai.chat.protocol import ChatModel
39
+ from ocai.core.credentials import Credential
40
+
41
+ __all__ = [
42
+ "CHAT_COMPLETIONS_CHUNK",
43
+ "CHAT_COMPLETIONS_REQUEST",
44
+ "CHAT_COMPLETIONS_RESPONSE",
45
+ "FRAGMENT",
46
+ "ChatCompletionsCodec",
47
+ "chat_model",
48
+ ]
49
+
50
+ # The frozen declarative fragment this interface contributes to a Catalog:
51
+ # its three nodes, its contract-censused edges, its chunk transducer, its
52
+ # capability profile, its hosting traits, and its codec factory (the
53
+ # component the §3.4 front door builds the serving codec from) — all stamped
54
+ # with this distribution's Origin.
55
+ FRAGMENT = CatalogFragment(
56
+ origin=ORIGIN,
57
+ nodes=NODES,
58
+ edges=EDGES,
59
+ transducers=TRANSDUCERS,
60
+ profiles=PROFILES,
61
+ traits=(dialect_traits(),),
62
+ components=(
63
+ Component(
64
+ provider=ChatCompletionsCodec.provider,
65
+ interface=ChatCompletionsCodec.interface,
66
+ capability=CHAT,
67
+ factory=ChatCompletionsCodec,
68
+ ),
69
+ ),
70
+ )
71
+
72
+
73
+ def chat_model(
74
+ model_name: str,
75
+ *,
76
+ credentials: Credential,
77
+ ) -> ChatModel:
78
+ """Sanctioned sugar (§3.4 door 2): a first-party OpenAI chat model.
79
+
80
+ A THIN wrapper over the exact same fragment + facade machinery as the
81
+ core string door — never a separate code path: it *is* a call to
82
+ `ocai.chat.model(f"openai/{model_name}", ...)` over a minimal catalog
83
+ built from this interface's `FRAGMENT` (so the front door composes a
84
+ `Direct` host at the OpenAI origin from the fragment's declared traits,
85
+ the `ChatCompletionsCodec` from the fragment's component factory, and an
86
+ `HttpxTransport`). The model name binds to the returned model object
87
+ here, at construction; `ChatRequest`s stay pure conversation and never
88
+ carry it. Power users compose the pieces themselves (any host, any
89
+ transport, richer catalogs); this factory adds nothing they cannot
90
+ reach.
91
+
92
+ Args:
93
+ model_name: The OpenAI model name bound to the returned model
94
+ object (the facade's `EndpointSpec.resource`).
95
+ credentials: The credential applied per request at the driver leaf.
96
+ Required — there is no ambient discovery (rule 13).
97
+
98
+ Returns:
99
+ A `ChatModel` implementation backed by this interface.
100
+ """
101
+ # Deferred import (the documented door-2 seam): the plugin stays inert at
102
+ # import time and reaches the wire only through the core front door, at
103
+ # call time — the same composition path, one frame deeper.
104
+ from ocai.chat import model
105
+
106
+ return model(
107
+ f"{ChatCompletionsCodec.provider}/{model_name}",
108
+ credentials=credentials,
109
+ catalog=Catalog.of(FRAGMENT),
110
+ )
@@ -0,0 +1,210 @@
1
+ """Pure client-facing Chat Completions mappings; serving and framing stay outside."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from copy import deepcopy
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+ from ocai.chat.types import ChatResponse
10
+ from ocai.chat.types import Part
11
+ from ocai.chat.types import PartRefusal
12
+ from ocai.chat.types import PartText
13
+ from ocai.chat.types import PartToolCall
14
+ from ocai.convert.graph import EdgeOutput
15
+ from ocai.core.fidelity import Disposition
16
+ from ocai.core.fidelity import FieldLoss
17
+ from ocai.core.fidelity import SemanticShift
18
+ from ocai.core.finish import FinishInfo
19
+ from ocai.core.ir import IRObject
20
+ from ocai.core.refs import ProviderRef
21
+ from ocai.core.refs import RefScope
22
+ from ocai.core.usage import Usage
23
+
24
+ _FINISH = {
25
+ "complete": "stop",
26
+ "stop_sequence": "stop",
27
+ "max_tokens": "length",
28
+ "tool_call": "tool_calls",
29
+ "content_filter": "content_filter",
30
+ }
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class ClientMetadata:
35
+ """Explicit gateway serving identity, never invented provider provenance."""
36
+
37
+ id: str
38
+ model: str
39
+ created: int
40
+
41
+ def bind(self, payload: dict[str, Any]) -> dict[str, Any]:
42
+ """Bind serving metadata to a detached response or chunk document."""
43
+ return {**deepcopy(payload), "id": self.id, "model": self.model, "created": self.created}
44
+
45
+
46
+ def _loss(
47
+ losses: list[FieldLoss],
48
+ path: str,
49
+ disposition: Disposition = Disposition.DROPPED,
50
+ note: str = "no lane in the qualified client contract",
51
+ ) -> None:
52
+ losses.append(FieldLoss(path=path, disposition=disposition, note=note))
53
+
54
+
55
+ def _extras(value: IRObject, path: str, losses: list[FieldLoss]) -> None:
56
+ if value.extensions:
57
+ _loss(losses, f"{path}.extensions")
58
+
59
+
60
+ def _ref_valid(ref: ProviderRef) -> bool:
61
+ return (
62
+ ref.provider == "openai"
63
+ and ref.scope != RefScope.MODEL
64
+ and (ref.interface == "chat_completions" or ref.scope == RefScope.PROVIDER)
65
+ )
66
+
67
+
68
+ def part_evidence(
69
+ part: Part, path: str, losses: list[FieldLoss], *, streaming: bool = False
70
+ ) -> None:
71
+ """Report part metadata with no client lane, including scoped anchors."""
72
+ _extras(part, path, losses)
73
+ if part.anchor is not None:
74
+ _loss(
75
+ losses,
76
+ f"{path}.anchor",
77
+ Disposition.DROPPED if _ref_valid(part.anchor) else Disposition.UNSUPPORTED,
78
+ )
79
+ if part.id is not None:
80
+ _loss(losses, f"{path}.id")
81
+ if part.cache_hint is not None:
82
+ _loss(losses, f"{path}.cache_hint")
83
+ if isinstance(part, PartText) and part.annotations:
84
+ _loss(losses, f"{path}.annotations")
85
+ if isinstance(part, PartToolCall):
86
+ if part.status != "input_complete" and not (streaming and part.status == "input_streaming"):
87
+ _loss(losses, f"{path}.status", Disposition.UNSUPPORTED)
88
+ if part.executed_by != "client":
89
+ _loss(losses, f"{path}.executed_by", Disposition.UNSUPPORTED)
90
+
91
+
92
+ def client_finish(finish: FinishInfo | None, losses: list[FieldLoss]) -> str | None:
93
+ """Map finish semantics while retaining unknown native values with findings."""
94
+ if finish is None:
95
+ return None
96
+ _extras(finish, "finish", losses)
97
+ if finish.detail:
98
+ _loss(losses, "finish.detail")
99
+ reason = _FINISH.get(finish.reason)
100
+ if reason is None:
101
+ _loss(losses, "finish.reason", Disposition.UNSUPPORTED)
102
+ return finish.native or finish.reason
103
+ if finish.native is not None and finish.native != reason:
104
+ _loss(losses, "finish.native", Disposition.RENAMED, "mapped by canonical finish meaning")
105
+ return reason
106
+
107
+
108
+ def client_usage(usage: Usage | None, losses: list[FieldLoss]) -> dict[str, Any] | None:
109
+ """Invert disjoint token buckets without inventing missing measurements."""
110
+ if usage is None:
111
+ return None
112
+ _extras(usage, "usage", losses)
113
+ buckets: dict[str, int] = {}
114
+ for i, item in enumerate(usage.consumptions):
115
+ if (
116
+ item.unit != "tokens"
117
+ or item.modality != "text"
118
+ or item.source not in {"input", "output", "cached", "reasoning"}
119
+ or not isinstance(item.value, int)
120
+ or isinstance(item.value, bool)
121
+ ):
122
+ _loss(losses, f"usage.consumptions[{i}]", Disposition.UNSUPPORTED)
123
+ continue
124
+ buckets[item.source] = buckets.get(item.source, 0) + item.value
125
+ result: dict[str, Any] = {}
126
+ if "input" in buckets:
127
+ result["prompt_tokens"] = buckets["input"] + buckets.get("cached", 0)
128
+ if "output" in buckets:
129
+ result["completion_tokens"] = buckets["output"] + buckets.get("reasoning", 0)
130
+ if "prompt_tokens" in result and "completion_tokens" in result:
131
+ result["total_tokens"] = result["prompt_tokens"] + result["completion_tokens"]
132
+ if "cached" in buckets:
133
+ result["prompt_tokens_details"] = {"cached_tokens": buckets["cached"]}
134
+ if "reasoning" in buckets:
135
+ result["completion_tokens_details"] = {"reasoning_tokens": buckets["reasoning"]}
136
+ return result or None
137
+
138
+
139
+ def encode_response(response: ChatResponse) -> EdgeOutput:
140
+ """Convert canonical response semantics; callers explicitly bind serving metadata."""
141
+ losses: list[FieldLoss] = []
142
+ shifts: list[SemanticShift] = []
143
+ message: dict[str, Any] = {"role": "assistant", "content": None}
144
+ text: list[str] = []
145
+ refusal: list[str] = []
146
+ calls: list[dict[str, Any]] = []
147
+ if response.message.role not in {"model", "assistant"}:
148
+ _loss(losses, "message.role", Disposition.UNSUPPORTED)
149
+ for i, part in enumerate(response.message.parts):
150
+ path = f"message.parts[{i}]"
151
+ part_evidence(part, path, losses)
152
+ if isinstance(part, PartText):
153
+ text.append(part.text)
154
+ elif isinstance(part, PartRefusal):
155
+ refusal.append(part.text)
156
+ elif isinstance(part, PartToolCall):
157
+ calls.append(
158
+ {
159
+ "id": part.call_id,
160
+ "type": "function",
161
+ "function": {"name": part.name, "arguments": part.args_json},
162
+ }
163
+ )
164
+ else:
165
+ _loss(losses, path, Disposition.UNSUPPORTED)
166
+ if text:
167
+ message["content"] = "".join(text)
168
+ if refusal:
169
+ message["refusal"] = "".join(refusal)
170
+ if calls:
171
+ message["tool_calls"] = calls
172
+ if len(response.message.parts) > 1 and (text or refusal):
173
+ shifts.append(
174
+ SemanticShift(
175
+ kind="relocation",
176
+ path="message.parts",
177
+ note="ordered parts grouped into client message lanes",
178
+ )
179
+ )
180
+ _extras(response.message, "message", losses)
181
+ _extras(response, "response", losses)
182
+ result: dict[str, Any] = {
183
+ "object": "chat.completion",
184
+ "choices": [
185
+ {
186
+ "index": 0,
187
+ "message": message,
188
+ "finish_reason": client_finish(response.finish, losses),
189
+ }
190
+ ],
191
+ }
192
+ usage = client_usage(response.usage, losses)
193
+ if usage is not None:
194
+ result["usage"] = usage
195
+ info = response.info
196
+ if info is not None:
197
+ _extras(info, "info", losses)
198
+ if info.id is not None:
199
+ if _ref_valid(info.id):
200
+ result["id"] = info.id.id
201
+ else:
202
+ _loss(losses, "info.id", Disposition.UNSUPPORTED)
203
+ if info.model is not None:
204
+ result["model"] = info.model
205
+ if info.created is not None:
206
+ if info.created.tzinfo is None or info.created.microsecond:
207
+ _loss(losses, "info.created", Disposition.UNSUPPORTED)
208
+ else:
209
+ result["created"] = int(info.created.timestamp())
210
+ return EdgeOutput(result, tuple(losses), tuple(shifts))