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.
- franca-0.1.0/.gitignore +67 -0
- franca-0.1.0/CHANGELOG.md +56 -0
- franca-0.1.0/LICENSE +21 -0
- franca-0.1.0/PKG-INFO +264 -0
- franca-0.1.0/README.md +234 -0
- franca-0.1.0/diagrams/README.md +56 -0
- franca-0.1.0/pyproject.toml +208 -0
- franca-0.1.0/src/franca/__init__.py +20 -0
- franca-0.1.0/src/franca/chat/__init__.py +12 -0
- franca-0.1.0/src/franca/chat/adapter.py +26 -0
- franca-0.1.0/src/franca/chat/dialects/__init__.py +16 -0
- franca-0.1.0/src/franca/chat/dialects/anthropic_messages.py +237 -0
- franca-0.1.0/src/franca/chat/dialects/google_generate_content.py +356 -0
- franca-0.1.0/src/franca/chat/dialects/openai_chat.py +287 -0
- franca-0.1.0/src/franca/chat/endpoints.py +164 -0
- franca-0.1.0/src/franca/chat/ir.py +385 -0
- franca-0.1.0/src/franca/chat/model.py +103 -0
- franca-0.1.0/src/franca/chat/profile.py +118 -0
- franca-0.1.0/src/franca/chat/profiles.py +221 -0
- franca-0.1.0/src/franca/core/__init__.py +5 -0
- franca-0.1.0/src/franca/core/_json.py +37 -0
- franca-0.1.0/src/franca/core/adapter.py +179 -0
- franca-0.1.0/src/franca/core/client.py +157 -0
- franca-0.1.0/src/franca/core/clock.py +85 -0
- franca-0.1.0/src/franca/core/connector.py +377 -0
- franca-0.1.0/src/franca/core/endpoint.py +126 -0
- franca-0.1.0/src/franca/core/enums.py +71 -0
- franca-0.1.0/src/franca/core/errors.py +221 -0
- franca-0.1.0/src/franca/core/ids.py +79 -0
- franca-0.1.0/src/franca/core/keys.py +84 -0
- franca-0.1.0/src/franca/core/model.py +279 -0
- franca-0.1.0/src/franca/core/profile.py +170 -0
- franca-0.1.0/src/franca/core/settings.py +274 -0
- franca-0.1.0/src/franca/core/sse.py +124 -0
- franca-0.1.0/src/franca/core/transport.py +139 -0
- franca-0.1.0/src/franca/core/types.py +219 -0
- franca-0.1.0/src/franca/py.typed +0 -0
- franca-0.1.0/src/franca/testing/__init__.py +135 -0
- franca-0.1.0/src/franca/transports/__init__.py +9 -0
- franca-0.1.0/src/franca/transports/httpx.py +256 -0
- franca-0.1.0/src/franca/transports/scripted.py +267 -0
- franca-0.1.0/tests/chat/cassettes/anthropic_401.json +14 -0
- franca-0.1.0/tests/chat/cassettes/anthropic_429.json +15 -0
- franca-0.1.0/tests/chat/cassettes/anthropic_text.json +15 -0
- franca-0.1.0/tests/chat/test_anthropic_adapter.py +369 -0
- franca-0.1.0/tests/chat/test_chat_ir.py +412 -0
- franca-0.1.0/tests/chat/test_chat_model.py +201 -0
- franca-0.1.0/tests/chat/test_chat_profile.py +239 -0
- franca-0.1.0/tests/chat/test_chat_profiles.py +125 -0
- franca-0.1.0/tests/chat/test_endpoints.py +129 -0
- franca-0.1.0/tests/chat/test_first_call.py +166 -0
- franca-0.1.0/tests/chat/test_google_adapter.py +410 -0
- franca-0.1.0/tests/chat/test_openai_adapter.py +298 -0
- franca-0.1.0/tests/contract/test_live.py +96 -0
- franca-0.1.0/tests/contract/test_provider_suite.py +439 -0
- franca-0.1.0/tests/core/test_adapter.py +125 -0
- franca-0.1.0/tests/core/test_adapter_abc.py +207 -0
- franca-0.1.0/tests/core/test_client.py +295 -0
- franca-0.1.0/tests/core/test_clock.py +163 -0
- franca-0.1.0/tests/core/test_connector.py +480 -0
- franca-0.1.0/tests/core/test_connector_sanitize.py +193 -0
- franca-0.1.0/tests/core/test_endpoint.py +248 -0
- franca-0.1.0/tests/core/test_errors.py +380 -0
- franca-0.1.0/tests/core/test_ids.py +88 -0
- franca-0.1.0/tests/core/test_json.py +71 -0
- franca-0.1.0/tests/core/test_keys.py +165 -0
- franca-0.1.0/tests/core/test_model.py +439 -0
- franca-0.1.0/tests/core/test_profile.py +248 -0
- franca-0.1.0/tests/core/test_settings.py +247 -0
- franca-0.1.0/tests/core/test_sse.py +289 -0
- franca-0.1.0/tests/core/test_testing_helpers.py +350 -0
- franca-0.1.0/tests/core/test_transport.py +280 -0
- franca-0.1.0/tests/core/test_types.py +226 -0
- franca-0.1.0/tests/mock/conftest.py +238 -0
- franca-0.1.0/tests/mock/fixtures/chat.json +48 -0
- franca-0.1.0/tests/mock/test_aimock_chat.py +421 -0
- franca-0.1.0/tests/test_franca_smoke.py +20 -0
- franca-0.1.0/tests/transports/test_httpx_transport.py +411 -0
- franca-0.1.0/tests/transports/test_scripted.py +215 -0
franca-0.1.0/.gitignore
ADDED
|
@@ -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
|
+
[](https://github.com/izmailov-labs/franca/actions/workflows/ci.yml)
|
|
34
|
+
[](https://pypi.org/project/franca/)
|
|
35
|
+
[](https://pypi.org/project/franca/)
|
|
36
|
+
[](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
|
+
[](https://github.com/izmailov-labs/franca/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/franca/)
|
|
5
|
+
[](https://pypi.org/project/franca/)
|
|
6
|
+
[](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).
|