ai_api_unified 2.35.0__tar.gz → 3.2.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. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/PKG-INFO +398 -3
  2. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/README.md +397 -2
  3. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/pyproject.toml +1 -1
  4. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/src/ai_api_unified/__init__.py +22 -0
  5. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/src/ai_api_unified/__version__.py +1 -1
  6. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/src/ai_api_unified/ai_base.py +614 -1
  7. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/src/ai_api_unified/ai_factory.py +679 -16
  8. ai_api_unified-3.2.0/src/ai_api_unified/ai_models_config.py +477 -0
  9. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/src/ai_api_unified/ai_provider_registry.py +127 -0
  10. ai_api_unified-3.2.0/src/ai_api_unified/classification/__init__.py +6 -0
  11. ai_api_unified-3.2.0/src/ai_api_unified/classification/ai_openai_decisions_classifier.py +554 -0
  12. ai_api_unified-3.2.0/src/ai_api_unified/classification/ai_structured_output_classifier.py +438 -0
  13. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/src/ai_api_unified/completions/ai_fallback_completions.py +2 -1
  14. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/src/ai_api_unified/completions/ai_openai_completions.py +10 -2
  15. ai_api_unified-3.2.0/src/ai_api_unified/completions/ai_router_completions.py +1404 -0
  16. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/src/ai_api_unified/pricing/pricing_registry.py +17 -0
  17. ai_api_unified-3.2.0/src/ai_api_unified/routing/__init__.py +23 -0
  18. ai_api_unified-3.2.0/src/ai_api_unified/routing/router_config.py +384 -0
  19. ai_api_unified-3.2.0/src/ai_api_unified/util/ai_model_checker.py +373 -0
  20. ai_api_unified-3.2.0/src/ai_api_unified/util/ai_model_probes.py +360 -0
  21. ai_api_unified-3.2.0/src/ai_api_unified/util/env_settings.py +168 -0
  22. ai_api_unified-2.35.0/src/ai_api_unified/util/env_settings.py +0 -93
  23. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/LICENSE +0 -0
  24. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/src/ai_api_unified/ai_anthropic_base.py +0 -0
  25. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/src/ai_api_unified/ai_bedrock_base.py +0 -0
  26. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/src/ai_api_unified/ai_completions_exceptions.py +0 -0
  27. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/src/ai_api_unified/ai_google_base.py +0 -0
  28. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/src/ai_api_unified/ai_openai_base.py +0 -0
  29. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/src/ai_api_unified/ai_provider_exceptions.py +0 -0
  30. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/src/ai_api_unified/ai_provider_loader.py +0 -0
  31. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/src/ai_api_unified/completions/__init__.py +0 -0
  32. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/src/ai_api_unified/completions/ai_anthropic_completions.py +0 -0
  33. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/src/ai_api_unified/completions/ai_bedrock_completions.py +0 -0
  34. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/src/ai_api_unified/completions/ai_google_gemini_capabilities.py +0 -0
  35. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/src/ai_api_unified/completions/ai_google_gemini_completions.py +0 -0
  36. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/src/ai_api_unified/completions/ai_openai_compatible_completions.py +0 -0
  37. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/src/ai_api_unified/completions/ai_openai_responses_completions.py +0 -0
  38. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/src/ai_api_unified/embeddings/__init__.py +0 -0
  39. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/src/ai_api_unified/embeddings/ai_google_gemini_embeddings.py +0 -0
  40. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/src/ai_api_unified/embeddings/ai_openai_embeddings.py +0 -0
  41. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/src/ai_api_unified/embeddings/ai_titan_embeddings.py +0 -0
  42. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/src/ai_api_unified/embeddings/ai_voyage_embeddings.py +0 -0
  43. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/src/ai_api_unified/images/__init__.py +0 -0
  44. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/src/ai_api_unified/images/ai_bedrock_images.py +0 -0
  45. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/src/ai_api_unified/images/ai_google_gemini_images.py +0 -0
  46. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/src/ai_api_unified/images/ai_openai_images.py +0 -0
  47. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/src/ai_api_unified/middleware/__init__.py +0 -0
  48. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/src/ai_api_unified/middleware/impl/__init__.py +0 -0
  49. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/src/ai_api_unified/middleware/impl/_presidio_redactor.py +0 -0
  50. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/src/ai_api_unified/middleware/impl/base_redactor.py +0 -0
  51. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/src/ai_api_unified/middleware/impl/custom_recognizer_factory.py +0 -0
  52. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/src/ai_api_unified/middleware/impl/noop_redactor.py +0 -0
  53. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/src/ai_api_unified/middleware/impl/presidio_log_control.py +0 -0
  54. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/src/ai_api_unified/middleware/middleware.py +0 -0
  55. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/src/ai_api_unified/middleware/middleware_config.py +0 -0
  56. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/src/ai_api_unified/middleware/observability.py +0 -0
  57. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/src/ai_api_unified/middleware/observability_runtime.py +0 -0
  58. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/src/ai_api_unified/middleware/pii_redactor.py +0 -0
  59. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/src/ai_api_unified/middleware/redaction_exceptions.py +0 -0
  60. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/src/ai_api_unified/pricing/__init__.py +0 -0
  61. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/src/ai_api_unified/pricing/model_pricing.py +0 -0
  62. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/src/ai_api_unified/util/__init__.py +0 -0
  63. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/src/ai_api_unified/util/_lazy_pydub.py +0 -0
  64. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/src/ai_api_unified/util/package_metadata_validation.py +0 -0
  65. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/src/ai_api_unified/util/utils.py +0 -0
  66. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/src/ai_api_unified/videos/__init__.py +0 -0
  67. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/src/ai_api_unified/videos/ai_bedrock_videos.py +0 -0
  68. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/src/ai_api_unified/videos/ai_google_gemini_videos.py +0 -0
  69. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/src/ai_api_unified/videos/ai_openai_videos.py +0 -0
  70. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/src/ai_api_unified/videos/frame_helpers.py +0 -0
  71. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/src/ai_api_unified/voice/__init__.py +0 -0
  72. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/src/ai_api_unified/voice/ai_voice_azure.py +0 -0
  73. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/src/ai_api_unified/voice/ai_voice_base.py +0 -0
  74. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/src/ai_api_unified/voice/ai_voice_elevenlabs.py +0 -0
  75. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/src/ai_api_unified/voice/ai_voice_factory.py +0 -0
  76. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/src/ai_api_unified/voice/ai_voice_google.py +0 -0
  77. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/src/ai_api_unified/voice/ai_voice_google_gemini_voices.py +0 -0
  78. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/src/ai_api_unified/voice/ai_voice_openai.py +0 -0
  79. {ai_api_unified-2.35.0 → ai_api_unified-3.2.0}/src/ai_api_unified/voice/audio_models.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: ai_api_unified
3
- Version: 2.35.0
3
+ Version: 3.2.0
4
4
  Summary: Unified access layer for AI completions (LLM), embedding (semantic), image, video, and voice services
5
5
  License: MIT
6
6
  Keywords: llm,openai,anthropic,claude,bedrock,gemini,vertex,embeddings,completion,video,voice,api
@@ -77,7 +77,7 @@ Project-URL: Issues, https://github.com/davecthomas/ai-api-unified/issues
77
77
  Project-URL: Repository, https://github.com/davecthomas/ai-api-unified
78
78
  Description-Content-Type: text/markdown
79
79
 
80
- # ai-api-unified 2.35.0
80
+ # ai-api-unified 3.2.0
81
81
 
82
82
  [![CI](https://github.com/davecthomas/ai-api-unified/actions/workflows/ci.yml/badge.svg)](https://github.com/davecthomas/ai-api-unified/actions/workflows/ci.yml)
83
83
  [![PyPI](https://img.shields.io/pypi/v/ai-api-unified.svg)](https://pypi.org/project/ai-api-unified/)
@@ -190,6 +190,7 @@ The public entry points are the stable base interfaces and factories:
190
190
  | ----------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
191
191
  | Completions | `AIBaseCompletions` | `openai`, `openai-responses`, `openai-compatible`, `claude`, `google-gemini`, Bedrock-routed aliases such as `nova`, `anthropic`, `llama`, `mistral`, `cohere`, `ai21`, `rerank` | `openai`, `anthropic`, `google_gemini`, `bedrock` |
192
192
  | Embeddings | `AIBaseEmbeddings` | `openai`, `titan`, `google-gemini`, `voyage` | `openai`, `bedrock`, `google_gemini`, `voyage` |
193
+ | Classification | `AIBaseClassifier` | `openai-decisions`, and `openai`, `openai-responses`, `openai-compatible`, `claude`, `google-gemini`, `bedrock`, `anthropic` through structured output | `openai`, `anthropic`, `google_gemini`, `bedrock` |
193
194
  | Images | `AIBaseImages` | `openai`, `google-gemini`, `nova-canvas` and Bedrock image aliases | `openai`, `google_gemini`, `bedrock` |
194
195
  | Videos | `AIBaseVideos` | `openai`, `google-gemini`, `nova-reel` and Bedrock video aliases | `openai`, `google_gemini`, `bedrock` |
195
196
  | Voice TTS | `AIVoiceBase` | `openai`, `google`, `azure`, `elevenlabs` | `openai`, `google_gemini`, `azure_tts`, `elevenlabs` |
@@ -900,6 +901,22 @@ Anthropic serves none.
900
901
  `AiProviderRequestError` carrying `status_code`, matching the completions
901
902
  clients.
902
903
 
904
+
905
+ ### Classification
906
+
907
+ ```python
908
+ from ai_api_unified import AIFactory, AIClassificationChoice
909
+
910
+ client = AIFactory.get_ai_classifier_client(classifier_engine="claude")
911
+ result = client.classify_choice(
912
+ "Summarize this ticket in one line.",
913
+ [AIClassificationChoice(value="easy"), AIClassificationChoice(value="hard")],
914
+ )
915
+ print(result.choice) # "easy"
916
+ ```
917
+
918
+ See the Classification section for the question types and the engines that
919
+ support them.
903
920
  ### Multimodal Embeddings
904
921
 
905
922
  Every embeddings client exposes a `capabilities` descriptor stating which input
@@ -1108,6 +1125,96 @@ than ignoring them.
1108
1125
  A blank retry-policy setting (`COMPLETIONS_RETRY_POLICY=`) is treated as
1109
1126
  unconfigured and falls back to `default` across every OpenAI-backed capability.
1110
1127
 
1128
+ ## Classification
1129
+
1130
+ A classifier answers one narrow question about a piece of text: which of
1131
+ your options fits it, whether a condition holds, or where it sits on a
1132
+ scale. It stands beside completions and embeddings as its own capability,
1133
+ so you can use it for routing, triage, or moderation without writing a
1134
+ prompt and parsing an answer. The model router (a later section) is one consumer.
1135
+
1136
+ ### Quickstart
1137
+
1138
+ ```python
1139
+ from ai_api_unified import AIFactory, AIClassificationChoice
1140
+
1141
+ client = AIFactory.get_ai_classifier_client() # CLASSIFIER_ENGINE from env
1142
+ result = client.classify_choice(
1143
+ "My invoice shows two charges for one order.",
1144
+ [
1145
+ AIClassificationChoice(value="billing", description="Payments, invoices, refunds."),
1146
+ AIClassificationChoice(value="technical", description="Errors using the product."),
1147
+ AIClassificationChoice(value="other", description="Anything else."),
1148
+ ],
1149
+ instructions="Which team should handle this message?",
1150
+ )
1151
+ print(result.choice, result.confidence, result.usage.input_tokens)
1152
+ ```
1153
+
1154
+ Three question types share one result model, `AIClassificationResult`:
1155
+
1156
+ | Method | Question | Result fields |
1157
+ | ------ | -------- | ------------- |
1158
+ | `classify_choice(text, choices)` | Which option fits? | `choice`, `confidence`, `probabilities` |
1159
+ | `classify_predicate(text, instructions=...)` | Does the condition hold? | `probability` |
1160
+ | `classify_score(text, levels)` | Where on the scale, lowest level first? | `score` in [0, 1], `choice` (the level label) |
1161
+
1162
+ Every result also carries `refused` (the model declined), `usage`,
1163
+ `token_source`, `provider_engine`, `model_name`, and `latency_ms`. Async
1164
+ twins (`aclassify_choice` and so on) exist where `capabilities.supports_async`
1165
+ is True. `capabilities` reports which question types the engine answers,
1166
+ `max_choices`, `max_input_chars`, and `pricing`.
1167
+
1168
+ Before the call, the text goes through the PII middleware when it is
1169
+ enabled, then is cut to `capabilities.max_input_chars` (6,000 by default)
1170
+ keeping its head and tail: a label rarely needs more, and the provider
1171
+ bills every input token.
1172
+
1173
+ ### Models that can classify
1174
+
1175
+ | Engine | How it classifies | Models |
1176
+ | ------ | ----------------- | ------ |
1177
+ | `openai-decisions` | OpenAI's Decisions API (`POST /v1/decisions`). Native choice, predicate, and score answers with probabilities. About ten times faster than a chat completion. Bills input tokens only, at $0.10 per 1M. | `gpt-6-luna` (public beta) |
1178
+ | `openai`, `openai-responses`, `openai-compatible` | Structured output (a one-field enum schema) | Every model the engine lists |
1179
+ | `claude` | Structured output | Every model the engine lists |
1180
+ | `google-gemini` | Structured output | Every model the engine lists |
1181
+ | `bedrock`, `anthropic` | Structured output | Models whose capabilities include `supports_structured_output` (Claude, DeepSeek, Qwen, GLM); Nova cannot classify |
1182
+
1183
+ Through structured output, a predicate answer is a hard yes or no
1184
+ (`probability` 1.0 or 0.0) and a score is the picked level's position on the
1185
+ scale; engines with native classification report real probabilities.
1186
+ Picking an engine without structured output fails at build with
1187
+ `AiProviderCapabilityUnsupportedError`.
1188
+
1189
+ ### Cost and metering
1190
+
1191
+ Every classification call runs inside the same observability lifecycle as
1192
+ completions, so with observability on it emits the usual input, output,
1193
+ error, and cost events, with `operation` set to `classify_choice`,
1194
+ `classify_predicate`, or `classify_score`. The Decisions engine prices
1195
+ under the registry key `decisions/gpt-6-luna`, since its rates differ from
1196
+ the model's chat rates; when a response carries no `usage` block the engine
1197
+ estimates input tokens from the request text and reports
1198
+ `token_source="estimated"`. The completions engine a
1199
+ structured-output classifier wraps meters the call, and that call carries
1200
+ the tag `classifier_role` (`choice`, `predicate`, or `score`) so a dashboard can
1201
+ split classifier spend from the engine's other traffic. `token_source` on
1202
+ the result says where the counts came from: `provider`, `estimated`, or
1203
+ `none`. A call that could not be metered logs a WARNING with
1204
+ `ai_classifier_event=classifier_unmetered` on the `ai_api_unified.ai_base`
1205
+ logger; it is never a silent zero.
1206
+
1207
+ ### Configuration
1208
+
1209
+ | Setting | Meaning |
1210
+ | ------- | ------- |
1211
+ | `CLASSIFIER_ENGINE` | Engine token. When unset, `openai-decisions` is used if `OPENAI_API_KEY` is set; otherwise the factory raises. |
1212
+ | `CLASSIFIER_MODEL_NAME` | Model override; blank falls back to `COMPLETIONS_MODEL_NAME` for structured-output engines, then the engine's default (`gpt-6-luna` for `openai-decisions`). |
1213
+ | `AIFactory.get_ai_classifier_client(classifier_engine=, model_name=, base_url=)` | Arguments override the settings. |
1214
+
1215
+ Exports: `AIBaseClassifier`, `AIClassifierCapabilitiesBase`,
1216
+ `AIClassificationChoice`, `AIClassificationLevel`, `AIClassificationResult`.
1217
+
1111
1218
  ## Model fallback
1112
1219
 
1113
1220
  A fallback chain keeps completions flowing when a model cannot serve a
@@ -1358,16 +1465,289 @@ poetry run pytest -m nonmock tests/test_model_fallback_nonmock.py -q
1358
1465
  It needs `ANTHROPIC_API_KEY` and `OPENAI_API_KEY` in `.env` and skips without
1359
1466
  them.
1360
1467
 
1468
+ ## Model routing
1469
+
1470
+ A router sends each request to a tier of models that matches how demanding
1471
+ the request is. A cheap classifier reads the latest user message, places it
1472
+ in one of your tiers (easy, medium, hard, or names of your own), and the
1473
+ request runs on that tier's model chain. A trivial question stops costing
1474
+ flagship rates; a hard one still gets the strongest model.
1475
+
1476
+ ### Why it exists
1477
+
1478
+ One configured model serves every prompt, whatever its difficulty. Routing
1479
+ by complexity lowers cost and latency on the easy majority without giving
1480
+ up quality on the hard minority, and it does so in the library, so an
1481
+ application changes nothing but configuration.
1482
+
1483
+ ### Setting up a router
1484
+
1485
+ Write the tiers as JSON, ascending capability. Each tier lists an ordered
1486
+ `engine:model` chain: the first entry is the tier's primary and the rest
1487
+ are its fallback chain. Tiers named `easy`, `medium`, and `hard` have
1488
+ built-in descriptions; any other name needs a `description` the classifier
1489
+ can read.
1490
+
1491
+ ```json
1492
+ {
1493
+ "tiers": [
1494
+ {"name": "easy", "models": ["openai:gpt-4.1-mini", "google-gemini:gemini-2.5-flash-lite"]},
1495
+ {"name": "medium", "models": ["claude:claude-sonnet-5", "openai:gpt-5.6-luna"]},
1496
+ {"name": "hard", "description": "Multi-step reasoning, long synthesis, cross-file code, or anything where a wrong answer is costly.",
1497
+ "models": ["claude:claude-opus-5-5"]}
1498
+ ],
1499
+ "classifier": {"engine": "openai-decisions", "model": "gpt-6-luna", "timeout_seconds": 5},
1500
+ "default_tier": "medium",
1501
+ "on_exhausted": "escalate"
1502
+ }
1503
+ ```
1504
+
1505
+ Point `COMPLETIONS_ROUTER_CONFIG` at the file (or set it to the JSON
1506
+ itself), keep `COMPLETIONS_ENGINE` and `COMPLETIONS_MODEL_NAME` as they
1507
+ are, and call the factory as usual:
1508
+
1509
+ ```python
1510
+ client = AIFactory.get_ai_completions_client() # an AiRouterCompletions
1511
+ print(client.send_prompt("What is 2 + 2?")) # routed to the easy tier
1512
+ print(client.last_tier, client.last_decision)
1513
+ ```
1514
+
1515
+ In code, pass `router=AIRouterConfig(...)` instead. The configured
1516
+ completions LLM, with its `COMPLETIONS_FALLBACKS` chain, is the **default
1517
+ route**: where calls go when no classifier is available, when the
1518
+ classifier fails, or when `default_tier` is unset.
1519
+
1520
+ ### The classifier
1521
+
1522
+ The router uses a classification engine (see Classification). Resolution
1523
+ order: `COMPLETIONS_ROUTER_CLASSIFIER` (`engine:model`), then the config's
1524
+ `classifier`, then the classification factory's own resolution
1525
+ (`CLASSIFIER_ENGINE`, then `openai-decisions` when `OPENAI_API_KEY` is
1526
+ set). Set either to `none` to route without a classifier; if the
1527
+ classifier cannot be built, the router logs `classifier_unavailable` once
1528
+ and degrades to the default route, and never raises at startup.
1529
+
1530
+ The classifier reads the latest user message only, cut to the engine's
1531
+ `max_input_chars`. The system prompt is left out: it is long, constant,
1532
+ and carries no complexity signal. The tier descriptions carry the policy,
1533
+ and `classifier.instructions` prepends your own guidance.
1534
+
1535
+ ### What happens on a request
1536
+
1537
+ 1. A per-call `route` option or `for_tier()` pins the tier (method
1538
+ `override`).
1539
+ 2. A history that already holds engine-shaped entries (tool turns) skips
1540
+ the classifier and stays in a tier whose engines share that family,
1541
+ preferring the tier that served the previous turn (method `pinned`).
1542
+ 3. Otherwise the classifier names a tier (method `classifier`). No
1543
+ classifier, an error, a refusal, or an unknown tier name routes to
1544
+ `default_tier` or the default route (method `default`) and logs
1545
+ `classifier_failed`.
1546
+ 4. The call runs on the tier's chain, which fails over inside the tier as
1547
+ any chain does.
1548
+ 5. If the chain runs out of candidates on a trigger reason
1549
+ (`fallback_on`), the router escalates to the next tier up and logs
1550
+ `tier_exhausted`; with `on_exhausted: raise`, on a non-trigger error, or
1551
+ on the top tier, the error propagates; if a tier's primary cannot be
1552
+ built, the router logs `tier_unbuildable` once and skips the tier the
1553
+ same way.
1554
+
1555
+ ### Seeing where a request went
1556
+
1557
+ Every call logs a `routed` record at INFO before it runs, on the logger
1558
+ `ai_api_unified.completions.ai_router_completions`, with an
1559
+ `ai_router_event` field so a log processor can count and alert without
1560
+ parsing the message:
1561
+
1562
+ | Level | `ai_router_event` | When |
1563
+ | ----- | ----------------- | ---- |
1564
+ | INFO | `routed` | Every call. Fields: `route_id`, `tier`, `tier_index`, `method`, `operation`, `classifier_engine`, `classifier_model`, `confidence`, `probabilities`, `classifier_latency_ms`, `classifier_input_tokens`, `classifier_token_source`, `classifier_usd_cost`, `caller_id`, `session_id`, `workflow_id`. |
1565
+ | WARNING | `classifier_unavailable` | Once at build: no classifier, with `reason` and `routed_to`. |
1566
+ | WARNING | `classifier_failed` | The classifier raised, refused, or named no tier; the call went to `routed_to`. Fields: `error`, `fallback_reason`, `latency_ms`. |
1567
+ | ERROR | `tier_exhausted` | A tier's chain ran out of candidates on a trigger reason; `escalate_to` names the next tier. |
1568
+ | ERROR | `tier_unbuildable` | A tier's primary could not be built; the tier is out for this process. |
1569
+
1570
+ The router also pushes `route_id`, `route_tier`, and `route_method` onto
1571
+ the observability context around the downstream call, so the engine's own
1572
+ input, output, and cost events carry them as `tag_route_tier` and so on:
1573
+ cost by tier needs no extra work. The classifier call itself carries
1574
+ `tag_router_role=classifier`.
1575
+
1576
+ ### Per-call control
1577
+
1578
+ - `provider_options={"route": "hard"}` pins one structured-output or
1579
+ conversation call to a tier; `"default"` pins it to the default route.
1580
+ The router removes the key before options reach the engine.
1581
+ - `client.for_tier("hard")` returns that tier's chain client, for
1582
+ `send_prompt` and streaming, which take no `provider_options`.
1583
+ - `client.last_tier`, `client.last_decision`, and `client.last_route_client`
1584
+ report the most recent call.
1585
+
1586
+ ### Configuration reference
1587
+
1588
+ | Setting | Meaning |
1589
+ | ------- | ------- |
1590
+ | `COMPLETIONS_ROUTER_CONFIG` | Path to a JSON file, or inline JSON, with `tiers`, optional `classifier`, `default_tier`, `on_exhausted`, `fallback_on`. |
1591
+ | `COMPLETIONS_ROUTER_CLASSIFIER` | `engine:model` for the router's classifier, or `none`. Overrides the config's `classifier`. |
1592
+ | `AIFactory.get_ai_completions_client(router=AIRouterConfig(...))` | Code-first configuration; overrides the setting. |
1593
+
1594
+ Exports: `AiRouterCompletions`, `AIRouterConfig`, `AIRouterTier`,
1595
+ `AIRouterClassifierConfig`, `load_router_config`.
1596
+
1597
+ ## Configured-model check
1598
+
1599
+ A fallback chain or a router names models the process expects to call
1600
+ later. A typo, a retired id, or a model not offered in your region would
1601
+ otherwise surface only when that model is finally tried, in the middle of
1602
+ a failover. So whenever you configure a chain or a router, the factory
1603
+ verifies every configured model before it builds the client: the
1604
+ completions LLM, each chain candidate, each tier candidate, and the
1605
+ router's classifier.
1606
+
1607
+ ### What it does
1608
+
1609
+ - One free metadata call per model, never a completion: OpenAI
1610
+ `models.retrieve`, Anthropic `models.retrieve`, Gemini `models.get` on
1611
+ the raw id, Bedrock `get_foundation_model` (or `get_inference_profile`
1612
+ for `us.`, `eu.`, `apac.`, and `global.` ids) in `AWS_REGION`, an
1613
+ OpenAI-compatible server's `/models` list, and the Decisions API's fixed
1614
+ model set.
1615
+ - Calls run in parallel, and the checker probes each `(engine, model,
1616
+ base_url)` once per process however many clients, chains, or tiers name
1617
+ it. Startup
1618
+ pays one round trip, and later factory calls pay nothing.
1619
+ - The factory prunes a model the provider reports **missing** from the
1620
+ effective configuration with a WARNING: `configured model openai:gpt-nope
1621
+ is not available and will be ignored`. A chain drops the candidate; a
1622
+ router tier drops the candidate, and the router drops a tier left with no
1623
+ models. A request for a dropped tier (by the classifier, a `route`
1624
+ override, or `for_tier`) goes to the next **higher** surviving tier, never
1625
+ a lower one, and to the default route when nothing is above. A dropped
1626
+ `default_tier` moves the same way.
1627
+ - The primary completions model cannot be pruned: it is the default
1628
+ route, so when it is missing the factory raises.
1629
+ - A missing classifier model logs `classifier_unavailable`, and the router
1630
+ degrades to its default route.
1631
+ - A model the probe cannot ask about (no credentials, no extra installed,
1632
+ credentials rejected, a server with no model catalog, Vertex auth) is
1633
+ **unverifiable** and kept, logged at INFO. A probe error (network, 5xx)
1634
+ keeps the model and logs a WARNING. Neither counts as missing.
1635
+
1636
+ Log records go to `ai_api_unified.util.ai_model_checker` with an
1637
+ `ai_model_check_event` field: `model_missing` (WARNING), `model_check_error`
1638
+ (WARNING), `model_unverifiable` (INFO), `model_available` (DEBUG); the
1639
+ factory adds `router_tier_pruned` and `router_disabled` (WARNING) on
1640
+ `ai_api_unified.ai_factory`. `AIFactory.last_model_check_report()` returns
1641
+ the results of the most recent check.
1642
+
1643
+ ### Configuration
1644
+
1645
+ | Setting | Meaning |
1646
+ | ------- | ------- |
1647
+ | `AI_MODEL_CHECK` | `prune` (default): drop missing models with a warning. `strict`: raise on any missing model. `off`: skip the check. |
1648
+
1649
+ A client with only a primary and no chain or router runs no check: the
1650
+ engine fails on its own at build.
1651
+
1652
+ ### Checking your own configuration
1653
+
1654
+ ```bash
1655
+ poetry run pytest -m nonmock tests/test_configured_models_nonmock.py -q -s --models-config ./models.json
1656
+ ```
1657
+
1658
+ prints every model the file names with its status and fails on any the
1659
+ provider reports missing. To watch the pruning itself:
1660
+
1661
+ ```bash
1662
+ poetry run pytest -m nonmock tests/test_model_check_nonmock.py -q -s
1663
+ ```
1664
+
1665
+ reads `tests/fixtures/models_missing_test.json`, whose chain and easy tier
1666
+ each list an id no provider serves, and proves both are reported missing
1667
+ and pruned with the warning while the configuration still builds; with
1668
+ `--models-config ./models.json` it does the same against your file,
1669
+ injecting a bogus id in memory.
1670
+
1361
1671
  ## Configuration
1362
1672
 
1673
+ ### models.json: one file for model selections
1674
+
1675
+ Engines, models, the fallback chain, the router, the classifier, the
1676
+ configured-model check, and the allow/deny policy live in one file,
1677
+ `models.json` (or `.yaml`). Copy `models_sample.json`, which sets every
1678
+ section, and point `AI_MODELS_CONFIG` at it (or leave it unset: the library
1679
+ picks up `./models.json` when it exists; a blank value turns the file off;
1680
+ the value may also be inline JSON).
1681
+
1682
+ ```json
1683
+ {
1684
+ "allow": ["claude:*", "google-gemini:*", "openai-decisions:*"],
1685
+ "deny": ["openai:gpt-4*", "bedrock"],
1686
+ "completions": {"engine": "google-gemini", "model": "gemini-2.5-flash",
1687
+ "fallbacks": ["claude:claude-sonnet-5"],
1688
+ "fallback_on": "unavailable,rate_limited,quota_exhausted,auth_rejected"},
1689
+ "classifier": {"engine": "openai-decisions", "model": "gpt-6-luna"},
1690
+ "router": {"tiers": [{"name": "easy", "models": ["google-gemini:gemini-2.5-flash-lite"]},
1691
+ {"name": "hard", "models": ["claude:claude-opus-5-5"]}],
1692
+ "default_tier": "easy"},
1693
+ "model_check": {"mode": "prune", "timeout_seconds": 10},
1694
+ "embeddings": {"engine": "google-gemini", "model": "gemini-embedding-001"},
1695
+ "images": {"engine": "google-gemini", "model": "gemini-3.1-flash-image"},
1696
+ "videos": {"engine": "google-gemini", "model": "veo-3.1-lite-generate-preview"},
1697
+ "voice": {"engine": "google", "tts_model": "gemini-2.5-pro-tts", "language": "en-US"}
1698
+ }
1699
+ ```
1700
+
1701
+ To convert an existing `.env`, run
1702
+
1703
+ ```bash
1704
+ poetry run python scripts/migrate2json.py # writes ./models.json
1705
+ poetry run python scripts/migrate2json.py --comment-env # and comments the moved keys out of .env
1706
+ ```
1707
+
1708
+ It places each selection key in its section, embeds the router file named
1709
+ by `COMPLETIONS_ROUTER_CONFIG`, fills structural defaults from
1710
+ `models_sample.json`, leaves the allow and deny lists empty, and never
1711
+ touches secrets.
1712
+
1713
+ Each section stands in for the `.env` keys of the same capability
1714
+ (`completions.model` for `COMPLETIONS_MODEL_NAME`, `embeddings.engine` for
1715
+ `EMBEDDING_ENGINE`, `router` for `COMPLETIONS_ROUTER_CONFIG`, and so on),
1716
+ so nothing else in the library changes. Precedence, highest first: an
1717
+ explicit factory argument, a process environment variable, a `.env` value,
1718
+ the file, the library default. Every key in the tables below still works
1719
+ and overrides the file when set, which lets a deployment pin one value
1720
+ without editing the file. Secrets (API keys, AWS credentials), `AWS_REGION`,
1721
+ base-URL overrides, and `AI_MIDDLEWARE_CONFIG_PATH` stay in `.env`.
1722
+
1723
+ ### Allow and deny lists
1724
+
1725
+ `allow` and `deny` hold `engine:model` globs: `claude:*`, `*:gpt-4*`,
1726
+ `openai-decisions:gpt-6-luna`; a bare engine token (`bedrock`) means
1727
+ `bedrock:*`. Deny wins over allow, and an empty allow list allows every
1728
+ model. The policy applies wherever a model enters: the completions LLM,
1729
+ each fallback candidate, each router tier candidate, the router's
1730
+ classifier, and the embeddings, image, and video selections.
1731
+
1732
+ - The factory prunes a denied fallback, tier, or classifier model the way
1733
+ it prunes a missing one (see Configured-model check) and warns
1734
+ `configured fallback model openai:gpt-4o is denied by policy pattern 'openai:gpt-4*' and will be ignored`
1735
+ (`ai_model_policy_event=model_denied`). A tier left with no models
1736
+ routes to the next higher surviving tier.
1737
+ - A denied primary, or an explicit `model_name=` argument for a denied
1738
+ model, raises `ValueError` naming the pattern. Runtime never calls a
1739
+ denied model.
1740
+
1363
1741
  ### Required Engine Selectors
1364
1742
 
1365
- There is no implicit default provider. Set the selector for each capability you use.
1743
+ There is no implicit default provider. Set the selector for each capability
1744
+ you use, in `models.json` or as a variable:
1366
1745
 
1367
1746
  | Environment variable | Valid values |
1368
1747
  | -------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
1369
1748
  | `COMPLETIONS_ENGINE` | `openai`, `openai-responses`, `openai-compatible`, `claude`, `google-gemini`, `bedrock`, Bedrock-routed aliases such as `nova`, `anthropic`, `llama`, `mistral`, `cohere`, `ai21`, `rerank` |
1370
1749
  | `EMBEDDING_ENGINE` | `openai`, `titan`, `google-gemini` |
1750
+ | `CLASSIFIER_ENGINE` | `openai-decisions`, `openai`, `openai-responses`, `openai-compatible`, `claude`, `google-gemini`, `bedrock`, `anthropic` (see Classification) |
1371
1751
  | `IMAGE_ENGINE` | `openai`, `google-gemini`, `nova-canvas`, `bedrock`, `nova` |
1372
1752
  | `VIDEO_ENGINE` | `openai`, `google-gemini`, `bedrock`, `nova`, `nova-reel` |
1373
1753
  | `AI_VOICE_ENGINE` | `openai`, `google`, `azure`, `elevenlabs` |
@@ -1379,7 +1759,13 @@ There is no implicit default provider. Set the selector for each capability you
1379
1759
  | `COMPLETIONS_MODEL_NAME` | Optional completions model override |
1380
1760
  | `COMPLETIONS_FALLBACKS` | Optional ordered `engine:model` pairs tried after the primary fails (see Model fallback) |
1381
1761
  | `COMPLETIONS_FALLBACK_ON` | Optional comma-separated `AiFallbackReason` values that trigger a fallback; default `unavailable,rate_limited,quota_exhausted,auth_rejected` |
1762
+ | `AI_MODELS_CONFIG` | Path to `models.json`/`.yaml`, or inline JSON; unset uses `./models.json` when present; blank turns the file off |
1763
+ | `COMPLETIONS_ROUTER_CONFIG` | Override of the file's `router` section: a JSON file path or inline JSON mapping tiers to `engine:model` chains (see Model routing) |
1764
+ | `COMPLETIONS_ROUTER_CLASSIFIER` | Optional `engine:model` for the router's classifier, or `none`; default is the classification factory's resolution |
1765
+ | `AI_MODEL_CHECK` | Optional: `prune` (default), `strict`, or `off` for the configured-model check (see Configured-model check) |
1766
+ | `AI_MODEL_CHECK_TIMEOUT_SECONDS` | Optional per-probe timeout for the configured-model check (default 10) |
1382
1767
  | `EMBEDDING_MODEL_NAME` | Optional embeddings model override. `gemini-embedding-2` enables multimodal embeddings. |
1768
+ | `CLASSIFIER_MODEL_NAME` | Optional classification model override; blank falls back to `COMPLETIONS_MODEL_NAME`, then the engine default |
1383
1769
  | `IMAGE_MODEL_NAME` | Optional image model override |
1384
1770
  | `VIDEO_MODEL_NAME` | Optional video model override |
1385
1771
  | `VIDEO_OUTPUT_DIR` | Optional local output directory for materialized video artifacts |
@@ -1927,6 +2313,15 @@ Live provider tests:
1927
2313
  poetry run pytest -m nonmock -s -vv
1928
2314
  ```
1929
2315
 
2316
+ Tests never read your own `models.json`: the suite blanks `AI_MODELS_CONFIG`.
2317
+ Live tests that need a models config read `tests/fixtures/models_test.json`,
2318
+ whose hard tier lists a model its own deny list rejects so the policy tests
2319
+ prove the deny list bites. To run them against any file:
2320
+
2321
+ ```bash
2322
+ poetry run pytest -m nonmock tests/test_model_policy_nonmock.py tests/test_configured_models_nonmock.py -q -s --models-config ./models.json
2323
+ ```
2324
+
1930
2325
  Notes for live tests:
1931
2326
 
1932
2327
  - install the matching provider extras first