franca 0.1.0__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.
Files changed (79) hide show
  1. franca-0.1.0/.gitignore +67 -0
  2. franca-0.1.0/CHANGELOG.md +56 -0
  3. franca-0.1.0/LICENSE +21 -0
  4. franca-0.1.0/PKG-INFO +264 -0
  5. franca-0.1.0/README.md +234 -0
  6. franca-0.1.0/diagrams/README.md +56 -0
  7. franca-0.1.0/pyproject.toml +208 -0
  8. franca-0.1.0/src/franca/__init__.py +20 -0
  9. franca-0.1.0/src/franca/chat/__init__.py +12 -0
  10. franca-0.1.0/src/franca/chat/adapter.py +26 -0
  11. franca-0.1.0/src/franca/chat/dialects/__init__.py +16 -0
  12. franca-0.1.0/src/franca/chat/dialects/anthropic_messages.py +237 -0
  13. franca-0.1.0/src/franca/chat/dialects/google_generate_content.py +356 -0
  14. franca-0.1.0/src/franca/chat/dialects/openai_chat.py +287 -0
  15. franca-0.1.0/src/franca/chat/endpoints.py +164 -0
  16. franca-0.1.0/src/franca/chat/ir.py +385 -0
  17. franca-0.1.0/src/franca/chat/model.py +103 -0
  18. franca-0.1.0/src/franca/chat/profile.py +118 -0
  19. franca-0.1.0/src/franca/chat/profiles.py +221 -0
  20. franca-0.1.0/src/franca/core/__init__.py +5 -0
  21. franca-0.1.0/src/franca/core/_json.py +37 -0
  22. franca-0.1.0/src/franca/core/adapter.py +179 -0
  23. franca-0.1.0/src/franca/core/client.py +157 -0
  24. franca-0.1.0/src/franca/core/clock.py +85 -0
  25. franca-0.1.0/src/franca/core/connector.py +377 -0
  26. franca-0.1.0/src/franca/core/endpoint.py +126 -0
  27. franca-0.1.0/src/franca/core/enums.py +71 -0
  28. franca-0.1.0/src/franca/core/errors.py +221 -0
  29. franca-0.1.0/src/franca/core/ids.py +79 -0
  30. franca-0.1.0/src/franca/core/keys.py +84 -0
  31. franca-0.1.0/src/franca/core/model.py +279 -0
  32. franca-0.1.0/src/franca/core/profile.py +170 -0
  33. franca-0.1.0/src/franca/core/settings.py +274 -0
  34. franca-0.1.0/src/franca/core/sse.py +124 -0
  35. franca-0.1.0/src/franca/core/transport.py +139 -0
  36. franca-0.1.0/src/franca/core/types.py +219 -0
  37. franca-0.1.0/src/franca/py.typed +0 -0
  38. franca-0.1.0/src/franca/testing/__init__.py +135 -0
  39. franca-0.1.0/src/franca/transports/__init__.py +9 -0
  40. franca-0.1.0/src/franca/transports/httpx.py +256 -0
  41. franca-0.1.0/src/franca/transports/scripted.py +267 -0
  42. franca-0.1.0/tests/chat/cassettes/anthropic_401.json +14 -0
  43. franca-0.1.0/tests/chat/cassettes/anthropic_429.json +15 -0
  44. franca-0.1.0/tests/chat/cassettes/anthropic_text.json +15 -0
  45. franca-0.1.0/tests/chat/test_anthropic_adapter.py +369 -0
  46. franca-0.1.0/tests/chat/test_chat_ir.py +412 -0
  47. franca-0.1.0/tests/chat/test_chat_model.py +201 -0
  48. franca-0.1.0/tests/chat/test_chat_profile.py +239 -0
  49. franca-0.1.0/tests/chat/test_chat_profiles.py +125 -0
  50. franca-0.1.0/tests/chat/test_endpoints.py +129 -0
  51. franca-0.1.0/tests/chat/test_first_call.py +166 -0
  52. franca-0.1.0/tests/chat/test_google_adapter.py +410 -0
  53. franca-0.1.0/tests/chat/test_openai_adapter.py +298 -0
  54. franca-0.1.0/tests/contract/test_live.py +96 -0
  55. franca-0.1.0/tests/contract/test_provider_suite.py +439 -0
  56. franca-0.1.0/tests/core/test_adapter.py +125 -0
  57. franca-0.1.0/tests/core/test_adapter_abc.py +207 -0
  58. franca-0.1.0/tests/core/test_client.py +295 -0
  59. franca-0.1.0/tests/core/test_clock.py +163 -0
  60. franca-0.1.0/tests/core/test_connector.py +480 -0
  61. franca-0.1.0/tests/core/test_connector_sanitize.py +193 -0
  62. franca-0.1.0/tests/core/test_endpoint.py +248 -0
  63. franca-0.1.0/tests/core/test_errors.py +380 -0
  64. franca-0.1.0/tests/core/test_ids.py +88 -0
  65. franca-0.1.0/tests/core/test_json.py +71 -0
  66. franca-0.1.0/tests/core/test_keys.py +165 -0
  67. franca-0.1.0/tests/core/test_model.py +439 -0
  68. franca-0.1.0/tests/core/test_profile.py +248 -0
  69. franca-0.1.0/tests/core/test_settings.py +247 -0
  70. franca-0.1.0/tests/core/test_sse.py +289 -0
  71. franca-0.1.0/tests/core/test_testing_helpers.py +350 -0
  72. franca-0.1.0/tests/core/test_transport.py +280 -0
  73. franca-0.1.0/tests/core/test_types.py +226 -0
  74. franca-0.1.0/tests/mock/conftest.py +238 -0
  75. franca-0.1.0/tests/mock/fixtures/chat.json +48 -0
  76. franca-0.1.0/tests/mock/test_aimock_chat.py +421 -0
  77. franca-0.1.0/tests/test_franca_smoke.py +20 -0
  78. franca-0.1.0/tests/transports/test_httpx_transport.py +411 -0
  79. franca-0.1.0/tests/transports/test_scripted.py +215 -0
@@ -0,0 +1,67 @@
1
+ # --- Secrets -----------------------------------------------------------
2
+ # An eval framework runs against provider APIs; keep keys out of the repo.
3
+ .env
4
+ .env.*
5
+ !.env.example
6
+ *.pem
7
+ *.key
8
+ secrets.toml
9
+
10
+ # --- Python ------------------------------------------------------------
11
+ __pycache__/
12
+ *.py[cod]
13
+ *$py.class
14
+ *.so
15
+ .Python
16
+
17
+ # --- Builds ------------------------------------------------------------
18
+ build/
19
+ dist/
20
+ *.egg
21
+ *.egg-info/
22
+ .eggs/
23
+
24
+ # --- Environments ------------------------------------------------------
25
+ .venv/
26
+ .venv*/
27
+ venv/
28
+ env/
29
+ .direnv/
30
+
31
+ # --- Tooling caches ----------------------------------------------------
32
+ .pytest_cache/
33
+ .mypy_cache/
34
+ .dmypy.json
35
+ dmypy.json
36
+ .ruff_cache/
37
+ .nox/
38
+ .tox/
39
+ .cache/
40
+ .hypothesis/
41
+ .ipynb_checkpoints/
42
+
43
+ # --- Coverage ----------------------------------------------------------
44
+ .coverage
45
+ .coverage.*
46
+ coverage.xml
47
+ htmlcov/
48
+
49
+ # --- Docs ---------------------------------------------------------------
50
+ # .gitignore has no inline comments - each pattern gets its own line.
51
+ # mkdocs build output
52
+ site/
53
+ # internal working notes; also excluded from the built site via mkdocs exclude_docs
54
+ docs/research/
55
+
56
+ # --- Logs / scratch ----------------------------------------------------
57
+ *.log
58
+ *.orig
59
+ *.rej
60
+ scratch/
61
+ tmp/
62
+
63
+ # --- Editors / OS ------------------------------------------------------
64
+ .DS_Store
65
+ .idea/
66
+ .vscode/
67
+ *.swp
@@ -0,0 +1,56 @@
1
+ # Changelog
2
+
3
+ All notable changes to this package are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2026-09-29
11
+
12
+ ### Added
13
+
14
+ - Extracted from the validia.dev workspace into a standalone repository; `whence`
15
+ is now consumed from PyPI (`whence>=1.0,<2`).
16
+ - `core/settings.py`: `Settings`, `ProviderSettings`, `RoutePolicy`, `RetryPolicy`,
17
+ `load_settings()` and `SettingsKeyProvider`. Loading is delegated to `whence`,
18
+ so a bad setting reports the file it came from; API keys are read at call time
19
+ and never stored in a model.
20
+ - `tests/mock/`: a `mock`-marked suite that drives the real `HttpxTransport` and SSE
21
+ path against [aimock](https://aimock.copilotkit.dev), a local mock LLM server the
22
+ suite spawns through `npx` (`make test-mock`; `make aimock` runs it by hand).
23
+ Deselected by default like `contract`; `AIMOCK_BASE_URL` reuses a running server.
24
+ - `GoogleGenerateContentAdapter`: the Gemini REST wire, the third chat dialect. It is
25
+ the first adapter to set `WireRequest.path`, because this wire carries the model
26
+ there and signals streaming by swapping the method rather than setting a body key.
27
+ Its `error_from` restores `failure_class="auth"` for the 400 `INVALID_ARGUMENT` that
28
+ Google answers a bad key with, reading the structured `API_KEY_INVALID` reason rather
29
+ than matching on message text.
30
+ - Endpoint rows for Google, xAI and DeepSeek, bringing the built-in table to five
31
+ providers across three dialects: xAI and DeepSeek are served by `OpenAIChatAdapter`
32
+ through rows of their own, so a compatibility surface costs data and no code. Each row
33
+ records what a deliberately wrong key actually returns, measured 2026-09-16: DeepSeek
34
+ answers `401`, which the connector's status map already classifies as `auth`, while xAI
35
+ answers `400` with a bare-string `error` body, so franca reports a wrong xAI key as a
36
+ non-retryable `provider` failure rather than `auth`. Refining that would mean matching
37
+ on message text, which the row documents instead of guessing.
38
+ - Provider default profile rows for `google`, `xai` and `deepseek`. Every capability on
39
+ them is `None`: no key for those providers was available, and an unmeasured claim is
40
+ not recorded as a measured one.
41
+ - `tests/chat/test_endpoints.py`: table-level invariants the individual rows cannot
42
+ state -- unique ids, a resolvable profile per provider, clean URL joins.
43
+ - Profile row for `claude-fable-5-1`, measured 2026-09-16 and identical to the rest of
44
+ the 5 generation: no sampling, adaptive thinking only, `output_config.effort`.
45
+
46
+ ### Fixed
47
+
48
+ - `prefill_allowed` is now `False` on `claude-sonnet-5` and `claude-opus-5`. A re-probe
49
+ on 2026-09-16 found the 5 generation answering an assistant prefill with
50
+ `400 This model does not support assistant message prefill`, where the 2026-09-07
51
+ sweep recorded it as accepted. The rows and their test assertions were corrected to
52
+ the wire. Nothing in franca changed between the sweeps; the provider did, which is
53
+ the case `verified` dates exist for.
54
+
55
+ [Unreleased]: https://github.com/izmailov-labs/franca/compare/v0.1.0...HEAD
56
+ [0.1.0]: https://github.com/izmailov-labs/franca/releases/tag/v0.1.0
franca-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 izmailov-labs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
franca-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,264 @@
1
+ Metadata-Version: 2.5
2
+ Name: franca
3
+ Version: 0.1.0
4
+ Summary: One request shape for every LLM wire dialect: transports, adapters, streaming, and a model registry.
5
+ Project-URL: Homepage, https://github.com/izmailov-labs/franca
6
+ Project-URL: Repository, https://github.com/izmailov-labs/franca
7
+ Project-URL: Changelog, https://github.com/izmailov-labs/franca/blob/main/CHANGELOG.md
8
+ Project-URL: Issues, https://github.com/izmailov-labs/franca/issues
9
+ Author: izmailov-labs
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: adapters,anthropic,api-client,async,gemini,llm,openai,streaming
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
20
+ Classifier: Topic :: Internet :: WWW/HTTP
21
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
22
+ Classifier: Topic :: Software Development :: Libraries
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.12
25
+ Requires-Dist: pydantic<3,>=2.12
26
+ Requires-Dist: whence<2,>=1.0
27
+ Provides-Extra: http
28
+ Requires-Dist: httpx>=0.28; extra == 'http'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # franca
32
+
33
+ [![CI](https://github.com/izmailov-labs/franca/actions/workflows/ci.yml/badge.svg)](https://github.com/izmailov-labs/franca/actions/workflows/ci.yml)
34
+ [![PyPI](https://img.shields.io/pypi/v/franca.svg)](https://pypi.org/project/franca/)
35
+ [![Python versions](https://img.shields.io/pypi/pyversions/franca.svg)](https://pypi.org/project/franca/)
36
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
37
+
38
+ One request shape for every LLM wire dialect.
39
+
40
+ A *lingua franca* for model APIs. You build one request, and franca decides which wire
41
+ shape the target model actually speaks, translates into it, and translates the answer
42
+ back. Three chat dialects are wired today behind one intermediate representation; five,
43
+ plus the image and video wires, are the target.
44
+
45
+ Three axes are kept separate, because they vary independently:
46
+
47
+ | Axis | Meaning | Owns |
48
+ | --- | --- | --- |
49
+ | **Provider** | who you authenticate with | endpoint rows, settings, key lookup |
50
+ | **Dialect** | the shape of the bytes | the adapter, the typed request, stream mapping |
51
+ | **Model** | the weights | the profile row that adapters read |
52
+
53
+ That separation is the point. A compatibility surface that serves one provider's wire
54
+ shape under another's host, and remaps model names on the way, needs no special case
55
+ anywhere else.
56
+
57
+ > **Status:** `0.1.0` — scaffolding. No public API yet.
58
+
59
+ ## What is wired today
60
+
61
+ Three dialects serve five providers, because a wire shape and a provider are different
62
+ things: xAI and DeepSeek speak OpenAI's bytes under their own hosts and keys, so they
63
+ are rows of data rather than code.
64
+
65
+ | Provider | Endpoint | Dialect | Auth header |
66
+ | --- | --- | --- | --- |
67
+ | `anthropic` | `/v1/messages` | `anthropic_messages` | `x-api-key` |
68
+ | `openai` | `/v1/chat/completions` | `openai_chat` | `Authorization` |
69
+ | `google` | `/v1beta/models/{model}:generateContent` | `google_generate_content` | `x-goog-api-key` |
70
+ | `xai` | `/v1/chat/completions` | `openai_chat` | `Authorization` |
71
+ | `deepseek` | `/v1/chat/completions` | `openai_chat` | `Authorization` |
72
+
73
+ Capability profiles are measured, never transcribed. Anthropic has six verified rows --
74
+ `haiku-4-5`, `sonnet-4-5`, `opus-4-5`, `sonnet-5`, `opus-5`, `fable-5-1` -- and every
75
+ other provider's default row claims nothing, because no probe returned a usable verdict:
76
+ no key was available for Google, xAI or DeepSeek, and OpenAI checks quota before it
77
+ validates parameters, so every probe there came back 429. An unverified capability reads
78
+ as `None`, which is not the same as `False`.
79
+
80
+ ## Models
81
+
82
+ Any model these five providers serve is reachable: the endpoint row carries the host and
83
+ the wire, and the model id is just a string in the request. What varies is how much franca
84
+ *knows* about a given model, and that is what the profile table answers.
85
+
86
+ A model id is normalised before it is matched, so every spelling of one model lands on one
87
+ row. An `anthropic.` / `openai.` / `google.` vendor namespace is stripped, as are the `[1m]`
88
+ context marker, an `@YYYYMMDD` or `-YYYYMMDD` snapshot date and `-latest`; the longest
89
+ matching `model_prefix` then wins, and that row is overlaid on the provider's default, so a
90
+ row states only what it measured and inherits the rest.
91
+
92
+ Six rows carry measured contracts, all Anthropic. Each cell below was obtained by sending
93
+ the parameter to the live API and recording whether the request was legal:
94
+
95
+ | Model prefix | Sampling | Thinking | Token budget | Effort | Prefill | Verified |
96
+ | --- | --- | --- | --- | --- | --- | --- |
97
+ | `claude-haiku-4-5` | yes | `budget` | yes | — | yes | 2026-09-07 |
98
+ | `claude-sonnet-4-5` | yes | `budget` | yes | — | yes | 2026-09-07 |
99
+ | `claude-opus-4-5` | yes | `budget` | yes | `high` | yes | 2026-09-07 |
100
+ | `claude-sonnet-5` | no | `adaptive` | no | `high` | no | 2026-09-16 |
101
+ | `claude-opus-5` | no | `adaptive` | no | `high` | no | 2026-09-16 |
102
+ | `claude-fable-5-1` | no | `adaptive` | no | `high` | no | 2026-09-16 |
103
+
104
+ Three groups, and the boundaries do not line up: `effort` splits `opus-4-5` away from its
105
+ own generation, while sampling and thinking split it the other way. That is the argument
106
+ for per-model rows over an `if provider ==` branch.
107
+
108
+ Every other model resolves to its provider's default row, which claims `streaming` and
109
+ nothing else — each remaining capability is `None`, meaning nobody has checked. `None` is
110
+ not `False`: franca sends the parameter rather than refusing the call locally.
111
+
112
+ ## Install
113
+
114
+ ```bash
115
+ uv add franca
116
+ ```
117
+
118
+ Requires Python 3.12+. Fully typed; ships a `py.typed` marker.
119
+
120
+ ## Making a call
121
+
122
+ Nothing below is re-exported from `franca` yet — the `0.1.0` status line above is honest,
123
+ and these are internal import paths that will move when the registry lands. They are the
124
+ shapes `tests/mock` drives over real HTTP, so they work today.
125
+
126
+ ### One request, one response
127
+
128
+ ```python
129
+ import asyncio
130
+
131
+ from franca.chat.dialects.anthropic_messages import AnthropicMessagesAdapter
132
+ from franca.chat.endpoints import ANTHROPIC_MESSAGES_ENDPOINT
133
+ from franca.chat.ir import Item, PromptPackage, SystemBlock
134
+ from franca.chat.model import ChatModel
135
+ from franca.chat.profiles import CHAT_PROFILES
136
+ from franca.core.clock import AsyncioClock
137
+ from franca.core.connector import Connector
138
+ from franca.core.ids import ANTHROPIC
139
+ from franca.core.settings import SettingsKeyProvider, load_settings
140
+ from franca.transports.httpx import HttpxTransport
141
+
142
+ MODEL = "claude-sonnet-5"
143
+
144
+
145
+ async def main() -> None:
146
+ transport = HttpxTransport() # needs the extra: uv add "franca[http]"
147
+ try:
148
+ model = ChatModel(
149
+ model=MODEL,
150
+ connector=Connector(
151
+ ANTHROPIC_MESSAGES_ENDPOINT,
152
+ # reads ANTHROPIC_API_KEY at call time, never stores it
153
+ keys=SettingsKeyProvider(load_settings()),
154
+ transport=transport,
155
+ clock=AsyncioClock(),
156
+ ),
157
+ adapter=AnthropicMessagesAdapter(),
158
+ profile=CHAT_PROFILES.resolve(ANTHROPIC, MODEL),
159
+ clock=AsyncioClock(),
160
+ )
161
+ res = await model.complete(
162
+ PromptPackage(
163
+ system=(SystemBlock(text="You are terse."),),
164
+ items=(Item(role="user", kind="text", text="Name one primary colour."),),
165
+ max_output_tokens=32,
166
+ )
167
+ )
168
+ print(res.text) # "Blue."
169
+ print(res.usage.input_tokens, res.usage.output_tokens)
170
+ print(res.served_model, res.stop_reason) # what the wire itself reported
171
+ print(res.trace.endpoint_id, res.trace.latency_ms)
172
+ finally:
173
+ await transport.aclose()
174
+
175
+
176
+ asyncio.run(main())
177
+ ```
178
+
179
+ `complete()` builds the wire request, sends it, translates the answer and stamps a
180
+ `CallTrace`. The response is dialect-neutral: `items` in the same vocabulary the request
181
+ used, `usage` with four counters, `stop_reason` in the wire's own words, `served_model` so
182
+ a silent alias swap is visible, and `raw` for the untranslated payload.
183
+
184
+ Switching providers changes three arguments and nothing else — the endpoint row, the
185
+ adapter and the provider the profile resolves against:
186
+
187
+ ```python
188
+ from franca.chat.dialects.openai_chat import OpenAIChatAdapter
189
+ from franca.chat.endpoints import XAI_CHAT_ENDPOINT
190
+ from franca.core.ids import XAI
191
+
192
+ # ...same ChatModel call, with:
193
+ # Connector(XAI_CHAT_ENDPOINT, ...), adapter=OpenAIChatAdapter(),
194
+ # profile=CHAT_PROFILES.resolve(XAI, "grok-4.6")
195
+ ```
196
+
197
+ ### Streaming
198
+
199
+ The IR-level stream is not built yet, and the leaf says so rather than shipping half a
200
+ feature. `ChatModel.stream()` raises a `ModelError` with `failure_class="unsupported"`:
201
+
202
+ ```text
203
+ streaming lands in M1; use complete() for now
204
+ ```
205
+
206
+ One level down works today. The adapter shapes a streaming request, the connector opens
207
+ the response as server-sent events, and `parse_sse` frames them — so you can consume raw
208
+ events now and swap to typed deltas when M1 lands:
209
+
210
+ ```python
211
+ wire = model.build(package, stream=True) # wire.stream is True
212
+ async with model.connector.stream(wire) as events:
213
+ async for event in events:
214
+ print(event.event, event.data)
215
+ # message_start {"type":"message_start","message":{...}}
216
+ ```
217
+
218
+ `SseEvent` is framing, not meaning: an `event` name, its `data`, and an `id` that persists
219
+ across blocks per WHATWG. The `[DONE]` sentinel is consumed by the parser rather than
220
+ yielded, and the stream ends when the body does. Reassembling text from the deltas is the
221
+ caller's job until the chat delta type exists.
222
+
223
+ Two dialect details the adapter already handles, so this loop does not have to: Anthropic
224
+ and the OpenAI-compatible wires set a body flag, while Google signals streaming by swapping
225
+ the path to `:streamGenerateContent?alt=sse`. `model.build(..., stream=True)` produces
226
+ whichever the selected row needs.
227
+
228
+ ## Development
229
+
230
+ ```bash
231
+ make install # sync the dev group, install pre-commit hooks
232
+ make all # lint, typecheck, coverage, build
233
+ ```
234
+
235
+ Live provider calls are opt-in: `make test-contract` runs them against real keys.
236
+
237
+ ### Testing against a mock model
238
+
239
+ [aimock](https://aimock.copilotkit.dev) serves the OpenAI and Anthropic wire shapes from
240
+ JSON fixtures, over real HTTP and real server-sent events, with no key and no bill. It
241
+ runs through `npx`, so node is the only prerequisite.
242
+
243
+ ```bash
244
+ make test-mock # spawns aimock on a free port, runs tests/mock, stops it
245
+ make aimock # runs it in the foreground on :4010 for your own experiments
246
+ ```
247
+
248
+ Point a model at it by rebasing an endpoint row. Only the host changes; the row's `id`,
249
+ and so every trace and cassette name, stays what it is in production:
250
+
251
+ ```python
252
+ from franca.chat.endpoints import OPENAI_CHAT_ENDPOINT
253
+
254
+ endpoint = OPENAI_CHAT_ENDPOINT.model_copy(update={"base_url": "http://127.0.0.1:4010"})
255
+ ```
256
+
257
+ `make aimock` starts the server with `AIMOCK_API_KEYS=franca-mock-key`, so send that key.
258
+ Fixtures live in `tests/mock/fixtures/` and match on the last user message; a request that
259
+ matches none comes back as a 404 `No fixture matched`. Set `AIMOCK_BASE_URL` to run the
260
+ suite against a server you started yourself.
261
+
262
+ ## License
263
+
264
+ MIT — see [LICENSE](LICENSE).
franca-0.1.0/README.md ADDED
@@ -0,0 +1,234 @@
1
+ # franca
2
+
3
+ [![CI](https://github.com/izmailov-labs/franca/actions/workflows/ci.yml/badge.svg)](https://github.com/izmailov-labs/franca/actions/workflows/ci.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/franca.svg)](https://pypi.org/project/franca/)
5
+ [![Python versions](https://img.shields.io/pypi/pyversions/franca.svg)](https://pypi.org/project/franca/)
6
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
7
+
8
+ One request shape for every LLM wire dialect.
9
+
10
+ A *lingua franca* for model APIs. You build one request, and franca decides which wire
11
+ shape the target model actually speaks, translates into it, and translates the answer
12
+ back. Three chat dialects are wired today behind one intermediate representation; five,
13
+ plus the image and video wires, are the target.
14
+
15
+ Three axes are kept separate, because they vary independently:
16
+
17
+ | Axis | Meaning | Owns |
18
+ | --- | --- | --- |
19
+ | **Provider** | who you authenticate with | endpoint rows, settings, key lookup |
20
+ | **Dialect** | the shape of the bytes | the adapter, the typed request, stream mapping |
21
+ | **Model** | the weights | the profile row that adapters read |
22
+
23
+ That separation is the point. A compatibility surface that serves one provider's wire
24
+ shape under another's host, and remaps model names on the way, needs no special case
25
+ anywhere else.
26
+
27
+ > **Status:** `0.1.0` — scaffolding. No public API yet.
28
+
29
+ ## What is wired today
30
+
31
+ Three dialects serve five providers, because a wire shape and a provider are different
32
+ things: xAI and DeepSeek speak OpenAI's bytes under their own hosts and keys, so they
33
+ are rows of data rather than code.
34
+
35
+ | Provider | Endpoint | Dialect | Auth header |
36
+ | --- | --- | --- | --- |
37
+ | `anthropic` | `/v1/messages` | `anthropic_messages` | `x-api-key` |
38
+ | `openai` | `/v1/chat/completions` | `openai_chat` | `Authorization` |
39
+ | `google` | `/v1beta/models/{model}:generateContent` | `google_generate_content` | `x-goog-api-key` |
40
+ | `xai` | `/v1/chat/completions` | `openai_chat` | `Authorization` |
41
+ | `deepseek` | `/v1/chat/completions` | `openai_chat` | `Authorization` |
42
+
43
+ Capability profiles are measured, never transcribed. Anthropic has six verified rows --
44
+ `haiku-4-5`, `sonnet-4-5`, `opus-4-5`, `sonnet-5`, `opus-5`, `fable-5-1` -- and every
45
+ other provider's default row claims nothing, because no probe returned a usable verdict:
46
+ no key was available for Google, xAI or DeepSeek, and OpenAI checks quota before it
47
+ validates parameters, so every probe there came back 429. An unverified capability reads
48
+ as `None`, which is not the same as `False`.
49
+
50
+ ## Models
51
+
52
+ Any model these five providers serve is reachable: the endpoint row carries the host and
53
+ the wire, and the model id is just a string in the request. What varies is how much franca
54
+ *knows* about a given model, and that is what the profile table answers.
55
+
56
+ A model id is normalised before it is matched, so every spelling of one model lands on one
57
+ row. An `anthropic.` / `openai.` / `google.` vendor namespace is stripped, as are the `[1m]`
58
+ context marker, an `@YYYYMMDD` or `-YYYYMMDD` snapshot date and `-latest`; the longest
59
+ matching `model_prefix` then wins, and that row is overlaid on the provider's default, so a
60
+ row states only what it measured and inherits the rest.
61
+
62
+ Six rows carry measured contracts, all Anthropic. Each cell below was obtained by sending
63
+ the parameter to the live API and recording whether the request was legal:
64
+
65
+ | Model prefix | Sampling | Thinking | Token budget | Effort | Prefill | Verified |
66
+ | --- | --- | --- | --- | --- | --- | --- |
67
+ | `claude-haiku-4-5` | yes | `budget` | yes | — | yes | 2026-09-07 |
68
+ | `claude-sonnet-4-5` | yes | `budget` | yes | — | yes | 2026-09-07 |
69
+ | `claude-opus-4-5` | yes | `budget` | yes | `high` | yes | 2026-09-07 |
70
+ | `claude-sonnet-5` | no | `adaptive` | no | `high` | no | 2026-09-16 |
71
+ | `claude-opus-5` | no | `adaptive` | no | `high` | no | 2026-09-16 |
72
+ | `claude-fable-5-1` | no | `adaptive` | no | `high` | no | 2026-09-16 |
73
+
74
+ Three groups, and the boundaries do not line up: `effort` splits `opus-4-5` away from its
75
+ own generation, while sampling and thinking split it the other way. That is the argument
76
+ for per-model rows over an `if provider ==` branch.
77
+
78
+ Every other model resolves to its provider's default row, which claims `streaming` and
79
+ nothing else — each remaining capability is `None`, meaning nobody has checked. `None` is
80
+ not `False`: franca sends the parameter rather than refusing the call locally.
81
+
82
+ ## Install
83
+
84
+ ```bash
85
+ uv add franca
86
+ ```
87
+
88
+ Requires Python 3.12+. Fully typed; ships a `py.typed` marker.
89
+
90
+ ## Making a call
91
+
92
+ Nothing below is re-exported from `franca` yet — the `0.1.0` status line above is honest,
93
+ and these are internal import paths that will move when the registry lands. They are the
94
+ shapes `tests/mock` drives over real HTTP, so they work today.
95
+
96
+ ### One request, one response
97
+
98
+ ```python
99
+ import asyncio
100
+
101
+ from franca.chat.dialects.anthropic_messages import AnthropicMessagesAdapter
102
+ from franca.chat.endpoints import ANTHROPIC_MESSAGES_ENDPOINT
103
+ from franca.chat.ir import Item, PromptPackage, SystemBlock
104
+ from franca.chat.model import ChatModel
105
+ from franca.chat.profiles import CHAT_PROFILES
106
+ from franca.core.clock import AsyncioClock
107
+ from franca.core.connector import Connector
108
+ from franca.core.ids import ANTHROPIC
109
+ from franca.core.settings import SettingsKeyProvider, load_settings
110
+ from franca.transports.httpx import HttpxTransport
111
+
112
+ MODEL = "claude-sonnet-5"
113
+
114
+
115
+ async def main() -> None:
116
+ transport = HttpxTransport() # needs the extra: uv add "franca[http]"
117
+ try:
118
+ model = ChatModel(
119
+ model=MODEL,
120
+ connector=Connector(
121
+ ANTHROPIC_MESSAGES_ENDPOINT,
122
+ # reads ANTHROPIC_API_KEY at call time, never stores it
123
+ keys=SettingsKeyProvider(load_settings()),
124
+ transport=transport,
125
+ clock=AsyncioClock(),
126
+ ),
127
+ adapter=AnthropicMessagesAdapter(),
128
+ profile=CHAT_PROFILES.resolve(ANTHROPIC, MODEL),
129
+ clock=AsyncioClock(),
130
+ )
131
+ res = await model.complete(
132
+ PromptPackage(
133
+ system=(SystemBlock(text="You are terse."),),
134
+ items=(Item(role="user", kind="text", text="Name one primary colour."),),
135
+ max_output_tokens=32,
136
+ )
137
+ )
138
+ print(res.text) # "Blue."
139
+ print(res.usage.input_tokens, res.usage.output_tokens)
140
+ print(res.served_model, res.stop_reason) # what the wire itself reported
141
+ print(res.trace.endpoint_id, res.trace.latency_ms)
142
+ finally:
143
+ await transport.aclose()
144
+
145
+
146
+ asyncio.run(main())
147
+ ```
148
+
149
+ `complete()` builds the wire request, sends it, translates the answer and stamps a
150
+ `CallTrace`. The response is dialect-neutral: `items` in the same vocabulary the request
151
+ used, `usage` with four counters, `stop_reason` in the wire's own words, `served_model` so
152
+ a silent alias swap is visible, and `raw` for the untranslated payload.
153
+
154
+ Switching providers changes three arguments and nothing else — the endpoint row, the
155
+ adapter and the provider the profile resolves against:
156
+
157
+ ```python
158
+ from franca.chat.dialects.openai_chat import OpenAIChatAdapter
159
+ from franca.chat.endpoints import XAI_CHAT_ENDPOINT
160
+ from franca.core.ids import XAI
161
+
162
+ # ...same ChatModel call, with:
163
+ # Connector(XAI_CHAT_ENDPOINT, ...), adapter=OpenAIChatAdapter(),
164
+ # profile=CHAT_PROFILES.resolve(XAI, "grok-4.6")
165
+ ```
166
+
167
+ ### Streaming
168
+
169
+ The IR-level stream is not built yet, and the leaf says so rather than shipping half a
170
+ feature. `ChatModel.stream()` raises a `ModelError` with `failure_class="unsupported"`:
171
+
172
+ ```text
173
+ streaming lands in M1; use complete() for now
174
+ ```
175
+
176
+ One level down works today. The adapter shapes a streaming request, the connector opens
177
+ the response as server-sent events, and `parse_sse` frames them — so you can consume raw
178
+ events now and swap to typed deltas when M1 lands:
179
+
180
+ ```python
181
+ wire = model.build(package, stream=True) # wire.stream is True
182
+ async with model.connector.stream(wire) as events:
183
+ async for event in events:
184
+ print(event.event, event.data)
185
+ # message_start {"type":"message_start","message":{...}}
186
+ ```
187
+
188
+ `SseEvent` is framing, not meaning: an `event` name, its `data`, and an `id` that persists
189
+ across blocks per WHATWG. The `[DONE]` sentinel is consumed by the parser rather than
190
+ yielded, and the stream ends when the body does. Reassembling text from the deltas is the
191
+ caller's job until the chat delta type exists.
192
+
193
+ Two dialect details the adapter already handles, so this loop does not have to: Anthropic
194
+ and the OpenAI-compatible wires set a body flag, while Google signals streaming by swapping
195
+ the path to `:streamGenerateContent?alt=sse`. `model.build(..., stream=True)` produces
196
+ whichever the selected row needs.
197
+
198
+ ## Development
199
+
200
+ ```bash
201
+ make install # sync the dev group, install pre-commit hooks
202
+ make all # lint, typecheck, coverage, build
203
+ ```
204
+
205
+ Live provider calls are opt-in: `make test-contract` runs them against real keys.
206
+
207
+ ### Testing against a mock model
208
+
209
+ [aimock](https://aimock.copilotkit.dev) serves the OpenAI and Anthropic wire shapes from
210
+ JSON fixtures, over real HTTP and real server-sent events, with no key and no bill. It
211
+ runs through `npx`, so node is the only prerequisite.
212
+
213
+ ```bash
214
+ make test-mock # spawns aimock on a free port, runs tests/mock, stops it
215
+ make aimock # runs it in the foreground on :4010 for your own experiments
216
+ ```
217
+
218
+ Point a model at it by rebasing an endpoint row. Only the host changes; the row's `id`,
219
+ and so every trace and cassette name, stays what it is in production:
220
+
221
+ ```python
222
+ from franca.chat.endpoints import OPENAI_CHAT_ENDPOINT
223
+
224
+ endpoint = OPENAI_CHAT_ENDPOINT.model_copy(update={"base_url": "http://127.0.0.1:4010"})
225
+ ```
226
+
227
+ `make aimock` starts the server with `AIMOCK_API_KEYS=franca-mock-key`, so send that key.
228
+ Fixtures live in `tests/mock/fixtures/` and match on the last user message; a request that
229
+ matches none comes back as a 404 `No fixture matched`. Set `AIMOCK_BASE_URL` to run the
230
+ suite against a server you started yourself.
231
+
232
+ ## License
233
+
234
+ MIT — see [LICENSE](LICENSE).