ai_api_unified 3.2.0__tar.gz → 3.6.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 (81) hide show
  1. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/PKG-INFO +362 -189
  2. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/README.md +361 -188
  3. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/pyproject.toml +1 -1
  4. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/__init__.py +21 -1
  5. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/__version__.py +1 -1
  6. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/ai_base.py +541 -73
  7. ai_api_unified-3.6.0/src/ai_api_unified/ai_completions_exceptions.py +107 -0
  8. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/ai_factory.py +112 -44
  9. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/ai_models_config.py +36 -0
  10. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/ai_provider_exceptions.py +8 -0
  11. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/completions/ai_anthropic_completions.py +92 -25
  12. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/completions/ai_bedrock_completions.py +152 -24
  13. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/completions/ai_fallback_completions.py +89 -4
  14. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/completions/ai_google_gemini_capabilities.py +2 -0
  15. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/completions/ai_google_gemini_completions.py +26 -23
  16. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/completions/ai_openai_completions.py +34 -23
  17. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/completions/ai_openai_responses_completions.py +22 -0
  18. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/completions/ai_router_completions.py +252 -172
  19. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/middleware/__init__.py +12 -0
  20. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/middleware/observability.py +112 -29
  21. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/middleware/observability_runtime.py +172 -51
  22. ai_api_unified-3.6.0/src/ai_api_unified/middleware/request_cost.py +736 -0
  23. ai_api_unified-3.6.0/src/ai_api_unified/thinking.py +545 -0
  24. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/util/ai_model_checker.py +104 -23
  25. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/util/ai_model_probes.py +117 -41
  26. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/util/env_settings.py +53 -0
  27. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/videos/ai_bedrock_videos.py +1 -1
  28. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/videos/ai_google_gemini_videos.py +4 -3
  29. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/videos/ai_openai_videos.py +3 -1
  30. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/voice/ai_voice_factory.py +1 -1
  31. ai_api_unified-3.2.0/src/ai_api_unified/ai_completions_exceptions.py +0 -51
  32. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/LICENSE +0 -0
  33. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/ai_anthropic_base.py +0 -0
  34. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/ai_bedrock_base.py +0 -0
  35. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/ai_google_base.py +0 -0
  36. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/ai_openai_base.py +0 -0
  37. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/ai_provider_loader.py +0 -0
  38. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/ai_provider_registry.py +0 -0
  39. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/classification/__init__.py +0 -0
  40. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/classification/ai_openai_decisions_classifier.py +0 -0
  41. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/classification/ai_structured_output_classifier.py +0 -0
  42. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/completions/__init__.py +0 -0
  43. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/completions/ai_openai_compatible_completions.py +0 -0
  44. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/embeddings/__init__.py +0 -0
  45. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/embeddings/ai_google_gemini_embeddings.py +0 -0
  46. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/embeddings/ai_openai_embeddings.py +0 -0
  47. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/embeddings/ai_titan_embeddings.py +0 -0
  48. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/embeddings/ai_voyage_embeddings.py +0 -0
  49. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/images/__init__.py +0 -0
  50. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/images/ai_bedrock_images.py +0 -0
  51. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/images/ai_google_gemini_images.py +0 -0
  52. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/images/ai_openai_images.py +0 -0
  53. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/middleware/impl/__init__.py +0 -0
  54. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/middleware/impl/_presidio_redactor.py +0 -0
  55. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/middleware/impl/base_redactor.py +0 -0
  56. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/middleware/impl/custom_recognizer_factory.py +0 -0
  57. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/middleware/impl/noop_redactor.py +0 -0
  58. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/middleware/impl/presidio_log_control.py +0 -0
  59. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/middleware/middleware.py +0 -0
  60. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/middleware/middleware_config.py +0 -0
  61. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/middleware/pii_redactor.py +0 -0
  62. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/middleware/redaction_exceptions.py +0 -0
  63. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/pricing/__init__.py +0 -0
  64. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/pricing/model_pricing.py +0 -0
  65. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/pricing/pricing_registry.py +0 -0
  66. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/routing/__init__.py +0 -0
  67. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/routing/router_config.py +0 -0
  68. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/util/__init__.py +0 -0
  69. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/util/_lazy_pydub.py +0 -0
  70. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/util/package_metadata_validation.py +0 -0
  71. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/util/utils.py +0 -0
  72. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/videos/__init__.py +0 -0
  73. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/videos/frame_helpers.py +0 -0
  74. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/voice/__init__.py +0 -0
  75. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/voice/ai_voice_azure.py +0 -0
  76. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/voice/ai_voice_base.py +0 -0
  77. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/voice/ai_voice_elevenlabs.py +0 -0
  78. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/voice/ai_voice_google.py +0 -0
  79. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/voice/ai_voice_google_gemini_voices.py +0 -0
  80. {ai_api_unified-3.2.0 → ai_api_unified-3.6.0}/src/ai_api_unified/voice/ai_voice_openai.py +0 -0
  81. {ai_api_unified-3.2.0 → ai_api_unified-3.6.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: 3.2.0
3
+ Version: 3.6.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 3.2.0
80
+ # ai-api-unified 3.6.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/)
@@ -117,11 +117,11 @@ The current package architecture is registry-backed and lazy-loaded:
117
117
  More than 100 models from 10 model makers (OpenAI, Anthropic, Google,
118
118
  Amazon, DeepSeek, Alibaba Qwen, Z.ai, Voyage AI, ElevenLabs, and Microsoft)
119
119
  behind one interface, plus any server that speaks the OpenAI API. Switching
120
- models is a configuration change: set the capability's engine (for example
121
- `COMPLETIONS_ENGINE`) and model (`COMPLETIONS_MODEL_NAME`).
120
+ models is a configuration change: set the capability's engine and model in
121
+ `models.json` (for example `completions.engine` and `completions.model`).
122
122
 
123
123
  <!-- supported-models:start -->
124
- | Capability | Provider | Engine (`*_ENGINE`) | Models |
124
+ | Capability | Provider | Engine (`<section>.engine`) | Models |
125
125
  | --- | --- | --- | --- |
126
126
  | Completions | OpenAI | `openai`, `openai-responses` | `gpt-6-astra`, `gpt-6-sol`, `gpt-6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna` (default), `gpt-5.5`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.4-nano`, `gpt-5.2`, `gpt-5.1-codex-max`, `gpt-5`, `gpt-5-mini`, `gpt-5-nano`, `gpt-4.1`, `gpt-4.1-mini`, `gpt-4.1-nano` ⚠ until 2026-10-23, `o4-mini` ⚠ until 2026-10-23, `gpt-4o`, `gpt-4o-mini` |
127
127
  | Completions | Anthropic | `claude` | `claude-fable-5-1`, `claude-opus-5-5`, `claude-fable-5`, `claude-opus-5` (default), `claude-sonnet-5`, `claude-opus-4-8`, `claude-opus-4-7`, `claude-opus-4-6`, `claude-sonnet-4-6`, `claude-haiku-4-5` |
@@ -131,7 +131,7 @@ models is a configuration change: set the capability's engine (for example
131
131
  | Completions | Amazon Bedrock: DeepSeek | `bedrock` | `deepseek.v3.2`, `us.deepseek.r1-v1:0` |
132
132
  | Completions | Amazon Bedrock: Qwen (Alibaba) | `bedrock` | `qwen.qwen3-next-80b-a3b`, `qwen.qwen3-235b-a22b-2507-v1:0`, `qwen.qwen3-coder-next`, `qwen.qwen3-32b-v1:0` |
133
133
  | Completions | Amazon Bedrock: GLM (Z.ai) | `bedrock` | `zai.glm-5`, `zai.glm-4.7`, `zai.glm-4.7-flash` |
134
- | Completions | Any OpenAI-compatible server (vLLM, Ollama, LiteLLM, ...) | `openai-compatible` | any model the server hosts (`COMPLETIONS_MODEL_NAME`) |
134
+ | Completions | Any OpenAI-compatible server (vLLM, Ollama, LiteLLM, ...) | `openai-compatible` | any model the server hosts (`completions.model`) |
135
135
  | Embeddings | OpenAI | `openai` | `text-embedding-3-small` (default), `text-embedding-3-large`, `text-embedding-ada-002` |
136
136
  | Embeddings | Google | `google-gemini` | `gemini-embedding-001` (default), `gemini-embedding-2` (multimodal) |
137
137
  | Embeddings | Voyage AI | `voyage` | `voyage-4-large`, `voyage-4`, `voyage-4-lite`, `voyage-code-4`, `voyage-3.5`, `voyage-3.5-lite`, `voyage-3-lite`, `voyage-3` (default), `voyage-3-large`, `voyage-code-3`, `voyage-finance-2`, `voyage-law-2` |
@@ -287,30 +287,34 @@ poetry install --all-extras --with dev
287
287
  | `similarity_score` | NumPy-based similarity helpers |
288
288
  | `dev` | Optional dev dependencies from `[project.optional-dependencies]` |
289
289
 
290
- ### Environment File
290
+ ### Environment File and models.json
291
291
 
292
- Copy [`env_template`](env_template) to `.env` and fill in only the providers you use.
293
-
294
- The OSS template defaults to Google API-key auth:
292
+ Two files: `.env` holds credentials and endpoint overrides; `models.json`
293
+ holds every model selection. Copy [`env_template`](env_template) to `.env`
294
+ and fill in only the providers you use, and copy
295
+ [`models_sample.json`](models_sample.json) to `models.json` and keep the
296
+ sections you need. The sample defaults to Google with API-key auth:
295
297
 
296
298
  ```dotenv
297
- COMPLETIONS_ENGINE=google-gemini
298
- EMBEDDING_ENGINE=google-gemini
299
- IMAGE_ENGINE=google-gemini
300
- VIDEO_ENGINE=google-gemini
301
- AI_VOICE_ENGINE=google
302
-
303
299
  GOOGLE_GEMINI_API_KEY=...
304
300
  GOOGLE_AUTH_METHOD=api_key
301
+ ```
305
302
 
306
- COMPLETIONS_MODEL_NAME=gemini-3.7-flash
307
- EMBEDDING_MODEL_NAME=gemini-embedding-001
308
- IMAGE_MODEL_NAME=gemini-3.1-flash-image
309
- VIDEO_MODEL_NAME=veo-3.1-lite-generate-preview
310
- DEFAULT_GEMINI_TTS_MODEL=gemini-2.5-pro-tts
303
+ ```json
304
+ {
305
+ "completions": {"engine": "google-gemini", "model": "gemini-3.7-flash", "thinking": "medium"},
306
+ "embeddings": {"engine": "google-gemini", "model": "gemini-embedding-001"},
307
+ "images": {"engine": "google-gemini", "model": "gemini-3.1-flash-image"},
308
+ "videos": {"engine": "google-gemini", "model": "veo-3.1-lite-generate-preview"},
309
+ "voice": {"engine": "google", "tts_model": "gemini-2.5-pro-tts"}
310
+ }
311
311
  ```
312
312
 
313
- Leave `EMBEDDING_DIMENSIONS` unset unless you deliberately want a provider-specific override. The library preserves provider defaults.
313
+ Leave `embeddings.dimensions` at `0` unless you deliberately want a
314
+ provider-specific size. The library preserves provider defaults. A `.env`
315
+ from before `models.json` still works: each selection key it carries
316
+ overrides the file and logs a deprecation warning; `scripts/migrate2json.py`
317
+ moves them (see Configuration).
314
318
 
315
319
  ### Smoke Test
316
320
 
@@ -334,13 +338,15 @@ print(response)
334
338
 
335
339
  ### Completions with automatic fallback
336
340
 
337
- Add one setting and the same client keeps serving when the primary model
341
+ Add one list and the same client keeps serving when the primary model
338
342
  cannot. The chain below runs Claude first, then OpenAI, then Gemini:
339
343
 
340
- ```bash
341
- COMPLETIONS_ENGINE=claude
342
- COMPLETIONS_MODEL_NAME=claude-opus-5
343
- COMPLETIONS_FALLBACKS=openai:gpt-5.6-luna,google-gemini:gemini-3.7-flash
344
+ ```json
345
+ "completions": {
346
+ "engine": "claude",
347
+ "model": "claude-opus-5",
348
+ "fallbacks": ["openai:gpt-5.6-luna", "google-gemini:gemini-3.7-flash"]
349
+ }
344
350
  ```
345
351
 
346
352
  ```python
@@ -396,7 +402,7 @@ if client.capabilities.supports_token_counting:
396
402
  ### OpenAI Responses engine
397
403
 
398
404
  OpenAI exposes two completions engines. The default `openai` engine uses Chat
399
- Completions; the `openai-responses` engine (`COMPLETIONS_ENGINE=openai-responses`)
405
+ Completions; the `openai-responses` engine (`completions.engine: "openai-responses"`)
400
406
  uses the Responses API, OpenAI's successor to Chat Completions. Both implement
401
407
  `send_prompt`, `strict_schema_prompt`, and
402
408
  `send_prompt_streaming`. The Responses engine is text-only for now; use the
@@ -411,10 +417,12 @@ surface as the `openai` engine: `send_prompt`, streaming,
411
417
  `send_conversation` with tools, `send_structured_output`,
412
418
  `strict_schema_prompt`, and the async variants.
413
419
 
420
+ ```json
421
+ "completions": {"engine": "openai-compatible", "model": "Qwen/Qwen3-32B"}
422
+ ```
423
+
414
424
  ```dotenv
415
- COMPLETIONS_ENGINE=openai-compatible
416
425
  OPENAI_COMPATIBLE_BASE_URL=http://localhost:8000/v1 # required
417
- COMPLETIONS_MODEL_NAME=Qwen/Qwen3-32B # required
418
426
  OPENAI_COMPATIBLE_API_KEY=... # optional for local servers
419
427
  OPENAI_COMPATIBLE_STRUCTURED_OUTPUT=json_schema # or json_object
420
428
  OPENAI_COMPATIBLE_CONTEXT_WINDOW=131072 # optional context guard
@@ -451,9 +459,11 @@ engine additionally implements the async variants and batch completions. Use
451
459
  `claude` for the current model lineup on Anthropic's own endpoint; use
452
460
  `anthropic` when your Claude access is provisioned through AWS.
453
461
 
462
+ ```json
463
+ "completions": {"engine": "claude", "model": "claude-opus-5"}
464
+ ```
465
+
454
466
  ```dotenv
455
- COMPLETIONS_ENGINE=claude
456
- COMPLETIONS_MODEL_NAME=claude-opus-5
457
467
  ANTHROPIC_API_KEY=...
458
468
  ```
459
469
 
@@ -510,6 +520,85 @@ has no per-call timeout). See the support matrix below.
510
520
  Unsupported combinations raise the typed `AiProviderCapabilityUnsupportedError`
511
521
  and each engine's `client.capabilities` flags report support at runtime.
512
522
 
523
+ ### Thinking levels
524
+
525
+ How much a model thinks before answering is one provider-neutral setting,
526
+ `AIThinkingLevel`, with six values: `off`, `minimal`, `low`, `medium`,
527
+ `high`, `max`. Configuration and application code use the level; each
528
+ engine applies it through its provider's own control, and no code or config
529
+ names a provider field. The default is `medium`.
530
+
531
+ ```python
532
+ from ai_api_unified import AIFactory, AIThinkingLevel
533
+
534
+ client = AIFactory.get_ai_completions_client(thinking="high") # or "thinking": "high" in models.json
535
+ client.thinking_level = AIThinkingLevel.LOW # change the default for later calls
536
+
537
+ text = client.send_prompt("...", other_params=params_with(thinking=AIThinkingLevel.MAX))
538
+ turn = client.send_conversation(system, messages, provider_options={"thinking_level": "off"})
539
+ result = client.send_structured_output(prompt, response_model=Summary,
540
+ provider_options={"thinking_level": "low"})
541
+
542
+ turn.thinking.requested, turn.thinking.applied, turn.thinking.exact # AIThinkingResolution
543
+ client.last_thinking # for send_prompt and friends
544
+ ```
545
+
546
+ Precedence: the call (`other_params.thinking` on the text methods; the
547
+ reserved `thinking_level` key in `provider_options` on `send_conversation`
548
+ and `send_structured_output`, stripped before the options reach the
549
+ provider), then the client (`thinking_level`, set by the `thinking=`
550
+ factory argument), then `completions.thinking` in `models.json`, then
551
+ `medium`. The level has no `.env` key: it is a model selection, and those
552
+ live in the file. The factory applies one level to the primary,
553
+ its fallbacks, and every router tier, so a request that fails over keeps its
554
+ level.
555
+
556
+ `client.capabilities.thinking` lists the standard levels the model honors
557
+ exactly (`None` when the model has no thinking control). A requested level
558
+ outside that list resolves to the nearest one the model supports, and a tie
559
+ goes to the lower level, since lower thinking is cheaper and the caller
560
+ asked for no more. The library records both levels: on `result.thinking` and
561
+ `client.last_thinking` (`requested`, `applied`, `exact`, and a `reason`
562
+ such as `unsupported_level`, `no_thinking_control`, `forced_tool_choice`,
563
+ `assistant_prefill`, or `max_tokens_too_small`), on every completions
564
+ observability event (`thinking_requested`, `thinking_applied`,
565
+ `thinking_exact`), and in an `ai_thinking_event` log line when they differ.
566
+
567
+ How each engine applies a level:
568
+
569
+ | Engine | Models | `off` | `minimal` | `low` / `medium` / `high` / `max` |
570
+ |---|---|---|---|---|
571
+ | `claude`, Bedrock Claude | Fable 5 / 5.1, Opus 5.5 (always on) | nearest: `low` | nearest: `low` | `output_config.effort` |
572
+ | `claude`, Bedrock Claude | Opus 5, Haiku 5.5, Sonnet 5, Opus 4.6 to 4.8, Sonnet 4.6 | `thinking.type: disabled` | nearest: `off` | `thinking.type: adaptive` + `output_config.effort` |
573
+ | `claude`, Bedrock Claude | Sonnet 5.5 | `thinking.type: between_tools` | nearest: `off` | `thinking.type: adaptive` + `output_config.effort` |
574
+ | `claude`, Bedrock Claude | Haiku 4.5, Sonnet 4.5, Opus 4.5, Opus 4.1, Sonnet 4, Opus 4, 3.7 Sonnet | omit `thinking` | nearest: `off` | `thinking.budget_tokens` 1024 / 4096 / 16384 / 32768, capped at `max_tokens` less 1024 kept for the answer |
575
+ | `claude`, Bedrock Claude | Claude 3 Opus and Haiku, 3.5 Sonnet and Haiku | no control | no control | no control |
576
+ | `openai`, `openai-responses` | GPT-5.1 and later, GPT-6 | `reasoning_effort: none` (not on GPT-6 Astra) | nearest | `low` / `medium` / `high` / `xhigh` |
577
+ | `openai`, `openai-responses` | GPT-5, GPT-5 mini, nano, pro, codex, and dated snapshots | nearest: `minimal` | `minimal` | `low` / `medium` / `high` / nearest: `high` |
578
+ | `openai`, `openai-responses` | o-series | nearest: `low` | nearest: `low` | `low` / `medium` / `high` / nearest: `high` |
579
+ | `openai`, `openai-compatible` | GPT-4.1, GPT-4o, uncataloged | no control | no control | no control |
580
+ | `google-gemini` | Gemini 3 Flash and later | nearest: `minimal` | `thinking_level: MINIMAL` | `LOW` / `MEDIUM` / `HIGH` / nearest: `HIGH` |
581
+ | `google-gemini` | Gemini 3 Pro | nearest: `low` | nearest: `low` | `LOW` / nearest / `HIGH` / nearest: `HIGH` |
582
+ | `google-gemini` | Gemini 2.5 Flash, Flash Lite | `thinking_budget: 0` | nearest: `off` | `thinking_budget` 1024 / 8192 / 16384 / 24576 |
583
+ | `google-gemini` | Gemini 2.5 Pro | nearest: `low` | nearest: `low` | `thinking_budget` 1024 / 8192 / 16384 / 32768 |
584
+ | Bedrock | Nova, DeepSeek, Qwen, GLM | no control | no control | no control |
585
+
586
+ On Bedrock the Claude fields travel under `additionalModelRequestFields`.
587
+ The engines merge these fields before `provider_options`, so a caller's own
588
+ `thinking`, `reasoning_effort`, or `thinking_config` still wins. Three
589
+ request shapes turn thinking off on models that allow it, with the reason
590
+ recorded: a forced `tool_choice` (Claude rejects the pair), an assistant
591
+ prefill (Bedrock's `strict_schema_prompt` on pre-4.6 Claude), and a
592
+ `max_tokens` with no room for the 1024-token minimum budget after 1024
593
+ tokens are kept for the answer. Bedrock's `send_prompt` and streaming
594
+ defaults of 256 tokens rise to 4096 on budget-era Claude models when the
595
+ caller sets no cap, so the configured level is applied.
596
+
597
+ The tables follow each provider's documentation as of October 2026 and the
598
+ live test (`poetry run pytest -m nonmock tests/test_thinking_levels_nonmock.py -q`)
599
+ exercises the configured models. When a model rejects a value the table
600
+ lists, the provider returns its own 400; correct the table entry.
601
+
513
602
  ### Structured output extraction (send_structured_output)
514
603
 
515
604
  `send_structured_output` is the single-shot extraction call: prose in, a parsed
@@ -695,7 +784,7 @@ Engine retry behavior:
695
784
  `AWS_MAX_ATTEMPTS` environment variable.
696
785
 
697
786
  Every engine accepts `retry_policy` at the constructor, through configuration
698
- (`COMPLETIONS_RETRY_POLICY=none`), or per call
787
+ (`completions.retry_policy: "none"` in `models.json`), or per call
699
788
  (`provider_options={"retry_policy": "none"}`; on Bedrock the per-call
700
789
  override collapses that call's schedule). HTTP-level failures raise
701
790
  `AiProviderRequestError`, whose `status_code` attribute lets caller backoff
@@ -897,7 +986,7 @@ Anthropic serves none.
897
986
  whose capabilities declare `supports_async` (currently `voyage`); others
898
987
  raise `AiProviderCapabilityUnsupportedError`.
899
988
  - The `voyage` engine honors `retry_policy="none"` (constructor or
900
- `COMPLETIONS_RETRY_POLICY=none`) and wraps provider failures in
989
+ `completions.retry_policy`) and wraps provider failures in
901
990
  `AiProviderRequestError` carrying `status_code`, matching the completions
902
991
  clients.
903
992
 
@@ -923,7 +1012,7 @@ Every embeddings client exposes a `capabilities` descriptor stating which input
923
1012
  types the configured model supports. Text-only models raise
924
1013
  `AiProviderCapabilityUnsupportedError` from `generate_embeddings_multimodal`.
925
1014
  Google `gemini-embedding-2` embeds interleaved text, images, video, audio, and
926
- PDFs into one vector space (set `EMBEDDING_MODEL_NAME=gemini-embedding-2`):
1015
+ PDFs into one vector space (set `embeddings.model` to `gemini-embedding-2`):
927
1016
 
928
1017
  ```python
929
1018
  from ai_api_unified import (
@@ -932,7 +1021,7 @@ from ai_api_unified import (
932
1021
  SupportedDataType,
933
1022
  )
934
1023
 
935
- client = AIFactory.get_ai_embedding_client() # EMBEDDING_MODEL_NAME=gemini-embedding-2
1024
+ client = AIFactory.get_ai_embedding_client() # embeddings.model: gemini-embedding-2
936
1025
 
937
1026
  if SupportedDataType.IMAGE in client.capabilities.supported_data_types:
938
1027
  with open("bicycle.png", "rb") as file_image:
@@ -1010,9 +1099,11 @@ print(result.job.status, result.artifacts[0].file_path)
1010
1099
 
1011
1100
  Environment:
1012
1101
 
1102
+ ```json
1103
+ "videos": {"engine": "google-gemini", "model": "veo-3.1-lite-generate-preview"}
1104
+ ```
1105
+
1013
1106
  ```dotenv
1014
- VIDEO_ENGINE=google-gemini
1015
- VIDEO_MODEL_NAME=veo-3.1-lite-generate-preview
1016
1107
  GOOGLE_GEMINI_API_KEY=...
1017
1108
  GOOGLE_AUTH_METHOD=api_key
1018
1109
  ```
@@ -1046,9 +1137,11 @@ for reference only; use the `google-gemini` or `nova-reel` video engine.
1046
1137
 
1047
1138
  Environment:
1048
1139
 
1140
+ ```json
1141
+ "videos": {"engine": "openai", "model": "sora-2"}
1142
+ ```
1143
+
1049
1144
  ```dotenv
1050
- VIDEO_ENGINE=openai
1051
- VIDEO_MODEL_NAME=sora-2
1052
1145
  OPENAI_API_KEY=...
1053
1146
  ```
1054
1147
 
@@ -1094,7 +1187,7 @@ with open(f"out{voice.default_audio_format.file_extension}", "wb") as output_fil
1094
1187
  `get_default_voice()` and `default_audio_format` supply the provider's own
1095
1188
  defaults. Use `stream_audio(text)` for the streaming variant.
1096
1189
 
1097
- `AIFactory.get_ai_voice_client()` reads `AI_VOICE_ENGINE` and accepts an
1190
+ `AIFactory.get_ai_voice_client()` reads `voice.engine` from `models.json` and accepts an
1098
1191
  explicit `voice_engine` override, matching the other capability constructors.
1099
1192
  It delegates to `AIVoiceFactory.create()`, which remains supported and callable
1100
1193
  with no arguments.
@@ -1107,7 +1200,7 @@ originating caller id unless the application sets one through the
1107
1200
  observability context.
1108
1201
 
1109
1202
  The OpenAI voice client shares `AIOpenAIBase` with the other OpenAI-backed
1110
- capabilities, so it honors `COMPLETIONS_RETRY_POLICY` and `OPENAI_BASE_URL_OVERRIDE`,
1203
+ capabilities, so it honors `completions.retry_policy` and `OPENAI_BASE_URL_OVERRIDE`,
1111
1204
  and accepts the same `retry_policy` and `base_url` constructor arguments:
1112
1205
 
1113
1206
  ```python
@@ -1122,8 +1215,8 @@ Only the `openai` voice engine accepts these; passing them to `google`,
1122
1215
  `azure`, or `elevenlabs` raises `AiProviderCapabilityUnsupportedError` rather
1123
1216
  than ignoring them.
1124
1217
 
1125
- A blank retry-policy setting (`COMPLETIONS_RETRY_POLICY=`) is treated as
1126
- unconfigured and falls back to `default` across every OpenAI-backed capability.
1218
+ A blank `completions.retry_policy` is treated as unconfigured and falls back
1219
+ to `default` across every OpenAI-backed capability.
1127
1220
 
1128
1221
  ## Classification
1129
1222
 
@@ -1138,7 +1231,7 @@ prompt and parsing an answer. The model router (a later section) is one consumer
1138
1231
  ```python
1139
1232
  from ai_api_unified import AIFactory, AIClassificationChoice
1140
1233
 
1141
- client = AIFactory.get_ai_classifier_client() # CLASSIFIER_ENGINE from env
1234
+ client = AIFactory.get_ai_classifier_client() # classifier.engine from models.json
1142
1235
  result = client.classify_choice(
1143
1236
  "My invoice shows two charges for one order.",
1144
1237
  [
@@ -1208,8 +1301,8 @@ logger; it is never a silent zero.
1208
1301
 
1209
1302
  | Setting | Meaning |
1210
1303
  | ------- | ------- |
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`). |
1304
+ | `classifier.engine` (`models.json`) | Engine token. When unset, `openai-decisions` is used if `OPENAI_API_KEY` is set; otherwise the factory raises. |
1305
+ | `classifier.model` | Model override; blank falls back to `completions.model` for structured-output engines, then the engine's default (`gpt-6-luna` for `openai-decisions`). |
1213
1306
  | `AIFactory.get_ai_classifier_client(classifier_engine=, model_name=, base_url=)` | Arguments override the settings. |
1214
1307
 
1215
1308
  Exports: `AIBaseClassifier`, `AIClassifierCapabilitiesBase`,
@@ -1238,20 +1331,21 @@ so it acts only on failures another model could serve.
1238
1331
 
1239
1332
  ### Setting up a chain
1240
1333
 
1241
- From configuration, which is the usual way:
1334
+ From `models.json`, which is the usual way:
1242
1335
 
1243
- ```bash
1244
- # The primary stays where it always was.
1245
- COMPLETIONS_ENGINE=claude
1246
- COMPLETIONS_MODEL_NAME=claude-opus-5
1247
-
1248
- # Ordered engine:model pairs to try after the primary fails.
1249
- COMPLETIONS_FALLBACKS=openai:gpt-5.6-luna,google-gemini:gemini-3.7-flash
1250
-
1251
- # Optional. Which failures trigger a move; this is the default.
1252
- COMPLETIONS_FALLBACK_ON=unavailable,rate_limited,quota_exhausted,auth_rejected
1336
+ ```json
1337
+ "completions": {
1338
+ "engine": "claude",
1339
+ "model": "claude-opus-5",
1340
+ "fallbacks": ["openai:gpt-5.6-luna", "google-gemini:gemini-3.7-flash"],
1341
+ "fallback_on": "unavailable,rate_limited,quota_exhausted,auth_rejected"
1342
+ }
1253
1343
  ```
1254
1344
 
1345
+ `fallbacks` lists the `engine:model` pairs to try, in order, after the
1346
+ primary fails; `fallback_on` names the failures that trigger a move (the
1347
+ value above is the default).
1348
+
1255
1349
  Or in code, which overrides the settings:
1256
1350
 
1257
1351
  ```python
@@ -1270,14 +1364,17 @@ client = AIFactory.get_ai_completions_client(
1270
1364
 
1271
1365
  Rules for the chain:
1272
1366
 
1273
- - The split in `COMPLETIONS_FALLBACKS` is on the first colon only, so Bedrock
1367
+ - The split in each `fallbacks` entry is on the first colon only, so Bedrock
1274
1368
  ids keep theirs: `bedrock:amazon.nova-lite-v1:0`. A pair with no model
1275
1369
  (`google-gemini`) uses that engine's default model.
1276
1370
  - Each fallback engine needs its own credentials and optional extra, exactly
1277
1371
  as it would if it were the primary.
1278
1372
  - The factory checks every fallback engine token against the provider
1279
1373
  registry when it builds the client, so a typo fails at startup, long
1280
- before any outage.
1374
+ before any outage. The [configured-model check](#configured-model-check)
1375
+ then probes each fallback model with its engine's credentials and prunes,
1376
+ with a WARNING, any that does not exist or cannot be reached (no key,
1377
+ rejected key, extra not installed).
1281
1378
  - Passing `fallbacks=[]` in code turns the chain off even when the setting
1282
1379
  is present.
1283
1380
 
@@ -1288,11 +1385,13 @@ Rules for the chain:
1288
1385
  2. If the engine raises `AiProviderRequestError` with a `fallback_reason` in
1289
1386
  the configured set, the client logs a warning naming both candidates and
1290
1387
  the reason, and sends the request to the next candidate.
1291
- 3. The client builds a fallback the first time a request needs it. When a
1292
- build fails (missing extra, bad credentials, unsupported option), the
1293
- client logs it once and skips that candidate for the rest of the
1294
- process, so a broken fallback never breaks startup or blocks the rest
1295
- of the chain.
1388
+ 3. The client builds a fallback the first time a request needs it. The
1389
+ configured-model check has already pruned fallbacks that are missing or
1390
+ unreachable, so a build failure here is rare (an unsupported option,
1391
+ for example). A key revoked after startup fails the request instead,
1392
+ as `AUTH_REJECTED` (see step 5). When a build fails, the client logs it
1393
+ once and skips that candidate for the rest of the process, so it never
1394
+ blocks the rest of the chain.
1296
1395
  4. The client skips, with a warning, a fallback that lacks a capability the
1297
1396
  call needs (structured output, tool use, async, streaming).
1298
1397
  5. After a `QUOTA_EXHAUSTED` or `AUTH_REJECTED` failure, the client skips
@@ -1310,11 +1409,12 @@ Rules for the chain:
1310
1409
  | `AUTH_REJECTED` | yes | 401/403, a revoked or invalid API key, a disabled organization, expired AWS credentials |
1311
1410
  | `MODEL_UNAVAILABLE` | no | 404, an unknown or retired model id, a model not offered in the region |
1312
1411
  | `TIMEOUT` | no | the call's `request_timeout_seconds` passed with no answer |
1313
- | `None` | never | validation errors, a safety refusal |
1412
+ | `INVALID_OUTPUT` | no | a structured answer that is not valid JSON, not a JSON object, or fails the response model (`StructuredOutputValidationError`) |
1413
+ | `None` | never | a safety refusal, request validation before any call |
1314
1414
 
1315
1415
  `MODEL_UNAVAILABLE` is off by default because an unknown model id usually
1316
1416
  means a configuration typo, and a working fallback would hide it. Turn it on
1317
- with `fallback_on` or `COMPLETIONS_FALLBACK_ON` when you want an unknown or
1417
+ with `fallback_on` when you want an unknown or
1318
1418
  retired model to fail over.
1319
1419
 
1320
1420
  `TIMEOUT` is off by default because the deadline is yours, and a failover
@@ -1336,6 +1436,15 @@ often a configuration error too; the `failover` and `served_by_fallback` log
1336
1436
  events (below) record it while traffic keeps flowing. Remove
1337
1437
  `auth_rejected` from `fallback_on` to make it fail loudly instead.
1338
1438
 
1439
+ `INVALID_OUTPUT` is off by default because the provider billed the bad answer, and
1440
+ failing over buys a second billed answer on another model: a spending
1441
+ decision. The error is `StructuredOutputValidationError`, a `ValueError` (so
1442
+ existing handlers keep working) that carries the discarded answer's `usage`,
1443
+ `raw_text`, and `finish_reason`. Turn the reason on to let the chain try the
1444
+ next model, and the router escalate, on bad output; each discarded answer
1445
+ then shows on the request's cost report as a wasted line item (see
1446
+ "Per-request cost").
1447
+
1339
1448
  A safety refusal is never a trigger: retrying a
1340
1449
  refused request on another model is a policy decision, not a reliability one,
1341
1450
  and it belongs in your code if you want it.
@@ -1443,8 +1552,8 @@ across the chain.
1443
1552
 
1444
1553
  | Setting | Meaning |
1445
1554
  | ------- | ------- |
1446
- | `COMPLETIONS_FALLBACKS` | Ordered `engine:model` pairs tried after the primary fails. Blank or unset means no chain. |
1447
- | `COMPLETIONS_FALLBACK_ON` | Comma-separated `AiFallbackReason` values that trigger a move. Default `unavailable,rate_limited,quota_exhausted,auth_rejected`. |
1555
+ | `completions.fallbacks` (`models.json`) | Ordered `engine:model` pairs tried after the primary fails. Empty or absent means no chain. |
1556
+ | `completions.fallback_on` | `AiFallbackReason` values (a list, or comma-separated) that trigger a move. Default `unavailable,rate_limited,quota_exhausted,auth_rejected`. |
1448
1557
  | `fallbacks=` (factory argument) | Same as the setting, as `(engine, model)` tuples or `AIFallbackCandidate` objects; overrides the setting. `[]` turns the chain off. |
1449
1558
  | `fallback_on=` (factory argument) | Same as the setting, as reasons or their values; overrides the setting. |
1450
1559
 
@@ -1502,9 +1611,8 @@ can read.
1502
1611
  }
1503
1612
  ```
1504
1613
 
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:
1614
+ Put it in the `router` section of `models.json`, keep `completions.engine`
1615
+ and `completions.model` as they are, and call the factory as usual:
1508
1616
 
1509
1617
  ```python
1510
1618
  client = AIFactory.get_ai_completions_client() # an AiRouterCompletions
@@ -1513,19 +1621,20 @@ print(client.last_tier, client.last_decision)
1513
1621
  ```
1514
1622
 
1515
1623
  In code, pass `router=AIRouterConfig(...)` instead. The configured
1516
- completions LLM, with its `COMPLETIONS_FALLBACKS` chain, is the **default
1624
+ completions LLM, with its `completions.fallbacks` chain, is the **default
1517
1625
  route**: where calls go when no classifier is available, when the
1518
1626
  classifier fails, or when `default_tier` is unset.
1519
1627
 
1520
1628
  ### The classifier
1521
1629
 
1522
1630
  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.
1631
+ order: the router's own `classifier` entry, then the top-level
1632
+ `classifier` section of `models.json`, then `openai-decisions` when
1633
+ `OPENAI_API_KEY` is set. Set either to `none` to route without a classifier. The
1634
+ configured-model check probes the classifier model with the tiers; if it
1635
+ is missing or unreachable, or the classifier cannot be built, the router
1636
+ logs `classifier_unavailable` once and degrades to the default route, and
1637
+ never raises at startup.
1529
1638
 
1530
1639
  The classifier reads the latest user message only, cut to the engine's
1531
1640
  `max_input_chars`. The system prompt is left out: it is long, constant,
@@ -1548,9 +1657,10 @@ and `classifier.instructions` prepends your own guidance.
1548
1657
  5. If the chain runs out of candidates on a trigger reason
1549
1658
  (`fallback_on`), the router escalates to the next tier up and logs
1550
1659
  `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.
1660
+ on the top tier, the error propagates. Tier models that are missing or
1661
+ unreachable were pruned at startup by the configured-model check; if a
1662
+ tier's primary still cannot be built, the router logs
1663
+ `tier_unbuildable` once and skips the tier the same way.
1554
1664
 
1555
1665
  ### Seeing where a request went
1556
1666
 
@@ -1587,8 +1697,8 @@ cost by tier needs no extra work. The classifier call itself carries
1587
1697
 
1588
1698
  | Setting | Meaning |
1589
1699
  | ------- | ------- |
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`. |
1700
+ | `router` (`models.json`) | `tiers`, optional `classifier`, `default_tier`, `on_exhausted`, `fallback_on`. |
1701
+ | `classifier` (`models.json`) | The classifier the router uses when its own `classifier` entry is absent; `{"engine": "none"}` routes without one. |
1592
1702
  | `AIFactory.get_ai_completions_client(router=AIRouterConfig(...))` | Code-first configuration; overrides the setting. |
1593
1703
 
1594
1704
  Exports: `AiRouterCompletions`, `AIRouterConfig`, `AIRouterTier`,
@@ -1596,13 +1706,33 @@ Exports: `AiRouterCompletions`, `AIRouterConfig`, `AIRouterTier`,
1596
1706
 
1597
1707
  ## Configured-model check
1598
1708
 
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.
1709
+ Every model the completions client may call is checked before the client
1710
+ is returned: the completions LLM, each fallback, each router tier
1711
+ candidate, and the router's classifier. The check asks two questions:
1712
+ does the provider serve this model, and can this process reach it with
1713
+ the credentials the engine will use? A typo, a retired id, a model not
1714
+ offered in your region, or a fallback whose key is missing then shows up
1715
+ at startup, not in the middle of a failover.
1716
+
1717
+ ### When it runs
1718
+
1719
+ - On every `AIFactory.get_ai_completions_client()` call, with or without
1720
+ a chain or router. A lone primary is checked too.
1721
+ - Each model is probed at most once per process for a given engine,
1722
+ model id, base URL, and credentials. Later factory calls reuse the
1723
+ result and make no network call. A probe runs again only when one of
1724
+ those four changes: an edited `models.json` that names a new model or
1725
+ base URL, or a rotated key. Credentials enter the memo key as a
1726
+ one-way hash of the settings the probe reads (for example
1727
+ `ANTHROPIC_API_KEY` and `ANTHROPIC_BASE_URL_OVERRIDE`); the key itself
1728
+ is never stored. AWS credentials from an instance role or an SSO cache
1729
+ are not in the hash; set `AWS_PROFILE` or the key variables when you
1730
+ need a rotation to force a recheck, or call
1731
+ `AiModelChecker.reset_cache()`.
1732
+ - A probe that failed for a transient reason (timeout, 5xx, dropped
1733
+ connection) is not cached, so the next factory call probes that model
1734
+ again.
1735
+ - `models.json` itself is reloaded when its modification time changes.
1606
1736
 
1607
1737
  ### What it does
1608
1738
 
@@ -1611,32 +1741,37 @@ router's classifier.
1611
1741
  the raw id, Bedrock `get_foundation_model` (or `get_inference_profile`
1612
1742
  for `us.`, `eu.`, `apac.`, and `global.` ids) in `AWS_REGION`, an
1613
1743
  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.
1744
+ model set. Calls run in parallel, so startup pays one round trip.
1745
+ - Each model gets one status:
1746
+
1747
+ | Status | Meaning | Effect |
1748
+ | ------ | ------- | ------ |
1749
+ | `available` | The provider returned the model's metadata. | Kept. |
1750
+ | `missing` | The provider has no such model (404, or an id outside a fixed catalog). | Pruned, WARNING. |
1751
+ | `unreachable` | The engine cannot call the model: no key, a key rejected as invalid (401, Gemini `API_KEY_INVALID`, Bedrock `UnrecognizedClient` / `InvalidSignature` / `ExpiredToken`, no AWS credentials), no base URL for an OpenAI-compatible server, or the engine's extra not installed. | Pruned, WARNING. |
1752
+ | `unverifiable` | The probe cannot ask, but nothing says the call would fail: the engine default model (no id configured), a permission denial (403, Bedrock `AccessDenied`) that may cover only the catalog call, Vertex auth, or a server without a model catalog. | Kept, INFO. |
1753
+ | `error` | The probe failed for another reason (timeout, 5xx, network). | Kept, WARNING; not cached. |
1754
+
1755
+ - A pruned fallback leaves the chain: `configured model openai:gpt-nope
1756
+ is not available and will be ignored`, or `configured model
1757
+ claude:claude-haiku-4-5 cannot be reached and will be ignored`. A
1758
+ pruned tier candidate leaves its tier, and the router drops a tier left
1759
+ with no models. A request for a dropped tier (by the classifier, a
1760
+ `route` override, or `for_tier`) goes to the next **higher** surviving
1761
+ tier, never a lower one, and to the default route when nothing is
1762
+ above. A dropped `default_tier` moves the same way.
1627
1763
  - 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.
1764
+ route. A missing primary raises `ValueError`; an unreachable one raises
1765
+ `AiProviderConfigurationError` (the engine's own error comes first when
1766
+ its constructor already refuses, for example a missing extra).
1767
+ - A missing or unreachable classifier model logs `classifier_unavailable`,
1768
+ and the router degrades to its default route.
1635
1769
 
1636
1770
  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
1771
+ `ai_model_check_event` field: `model_missing` (WARNING),
1772
+ `model_unreachable` (WARNING), `model_check_error` (WARNING),
1773
+ `model_unverifiable` (INFO), `model_available` (DEBUG); the factory adds
1774
+ `router_tier_pruned` and `router_disabled` (WARNING) on
1640
1775
  `ai_api_unified.ai_factory`. `AIFactory.last_model_check_report()` returns
1641
1776
  the results of the most recent check.
1642
1777
 
@@ -1644,10 +1779,7 @@ the results of the most recent check.
1644
1779
 
1645
1780
  | Setting | Meaning |
1646
1781
  | ------- | ------- |
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.
1782
+ | `model_check.mode` (`models.json`) | `prune` (default): drop missing and unreachable fallbacks and tier models with a warning. `strict`: raise on any of them. `off`: skip the check. `model_check.timeout_seconds` bounds each probe (default 10). |
1651
1783
 
1652
1784
  ### Checking your own configuration
1653
1785
 
@@ -1655,8 +1787,8 @@ engine fails on its own at build.
1655
1787
  poetry run pytest -m nonmock tests/test_configured_models_nonmock.py -q -s --models-config ./models.json
1656
1788
  ```
1657
1789
 
1658
- prints every model the file names with its status and fails on any the
1659
- provider reports missing. To watch the pruning itself:
1790
+ prints every model the file names with its status and fails on any that
1791
+ is missing or unreachable. To watch the pruning itself:
1660
1792
 
1661
1793
  ```bash
1662
1794
  poetry run pytest -m nonmock tests/test_model_check_nonmock.py -q -s
@@ -1683,7 +1815,7 @@ the value may also be inline JSON).
1683
1815
  {
1684
1816
  "allow": ["claude:*", "google-gemini:*", "openai-decisions:*"],
1685
1817
  "deny": ["openai:gpt-4*", "bedrock"],
1686
- "completions": {"engine": "google-gemini", "model": "gemini-2.5-flash",
1818
+ "completions": {"engine": "google-gemini", "model": "gemini-2.5-flash", "thinking": "medium",
1687
1819
  "fallbacks": ["claude:claude-sonnet-5"],
1688
1820
  "fallback_on": "unavailable,rate_limited,quota_exhausted,auth_rejected"},
1689
1821
  "classifier": {"engine": "openai-decisions", "model": "gpt-6-luna"},
@@ -1705,19 +1837,17 @@ poetry run python scripts/migrate2json.py # writes ./models.json
1705
1837
  poetry run python scripts/migrate2json.py --comment-env # and comments the moved keys out of .env
1706
1838
  ```
1707
1839
 
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.
1840
+ It places each selection in its section, embeds a router file the old
1841
+ `.env` pointed at, fills structural defaults from `models_sample.json`,
1842
+ leaves the allow and deny lists empty, and never touches secrets.
1712
1843
 
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`,
1844
+ The old selection keys are deprecated. One left in `.env` or the process
1845
+ environment is still accepted and overrides the file, so a deployment does
1846
+ not break, and the library logs one warning per key naming the
1847
+ `models.json` field and the migrator command
1848
+ (`ai_config_event=deprecated_selection_key`). Precedence, highest first: an
1849
+ explicit factory argument, a deprecated environment key, the file, the
1850
+ library default. Secrets (API keys, AWS credentials), `AWS_REGION`,
1721
1851
  base-URL overrides, and `AI_MIDDLEWARE_CONFIG_PATH` stay in `.env`.
1722
1852
 
1723
1853
  ### Allow and deny lists
@@ -1738,43 +1868,43 @@ classifier, and the embeddings, image, and video selections.
1738
1868
  model, raises `ValueError` naming the pattern. Runtime never calls a
1739
1869
  denied model.
1740
1870
 
1741
- ### Required Engine Selectors
1871
+ ### Engine selectors
1872
+
1873
+ There is no implicit default provider. Set the engine for each capability
1874
+ you use in `models.json`:
1875
+
1876
+ | `models.json` field | Valid values |
1877
+ | ------------------- | ------------ |
1878
+ | `completions.engine` | `openai`, `openai-responses`, `openai-compatible`, `claude`, `google-gemini`, `bedrock`, Bedrock-routed aliases such as `nova`, `anthropic`, `llama`, `mistral`, `cohere`, `ai21`, `rerank` |
1879
+ | `embeddings.engine` | `openai`, `titan`, `google-gemini`, `voyage` |
1880
+ | `classifier.engine` | `openai-decisions`, `openai`, `openai-responses`, `openai-compatible`, `claude`, `google-gemini`, `bedrock`, `anthropic` (see Classification) |
1881
+ | `images.engine` | `openai`, `google-gemini`, `nova-canvas`, `bedrock`, `nova` |
1882
+ | `videos.engine` | `openai`, `google-gemini`, `bedrock`, `nova`, `nova-reel` |
1883
+ | `voice.engine` | `openai`, `google`, `azure`, `elevenlabs` |
1884
+
1885
+ ### Model selections
1742
1886
 
1743
- There is no implicit default provider. Set the selector for each capability
1744
- you use, in `models.json` or as a variable:
1887
+ Every section of `models.json` is documented in
1888
+ [`models_sample.json`](models_sample.json). The fields, by section:
1745
1889
 
1746
- | Environment variable | Valid values |
1747
- | -------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
1748
- | `COMPLETIONS_ENGINE` | `openai`, `openai-responses`, `openai-compatible`, `claude`, `google-gemini`, `bedrock`, Bedrock-routed aliases such as `nova`, `anthropic`, `llama`, `mistral`, `cohere`, `ai21`, `rerank` |
1749
- | `EMBEDDING_ENGINE` | `openai`, `titan`, `google-gemini` |
1750
- | `CLASSIFIER_ENGINE` | `openai-decisions`, `openai`, `openai-responses`, `openai-compatible`, `claude`, `google-gemini`, `bedrock`, `anthropic` (see Classification) |
1751
- | `IMAGE_ENGINE` | `openai`, `google-gemini`, `nova-canvas`, `bedrock`, `nova` |
1752
- | `VIDEO_ENGINE` | `openai`, `google-gemini`, `bedrock`, `nova`, `nova-reel` |
1753
- | `AI_VOICE_ENGINE` | `openai`, `google`, `azure`, `elevenlabs` |
1890
+ | Section | Fields |
1891
+ | ------- | ------ |
1892
+ | `completions` | `engine`, `model`, `fallbacks` (see Model fallback), `fallback_on`, `retry_policy`, `thinking` (see Thinking levels) |
1893
+ | `classifier` | `engine`, `model`, `base_url`, `timeout_seconds`, `instructions` |
1894
+ | `router` | `tiers`, `classifier`, `default_tier`, `on_exhausted`, `fallback_on` (see Model routing) |
1895
+ | `model_check` | `mode` (`prune`, `strict`, `off`), `timeout_seconds` (see Configured-model check) |
1896
+ | `embeddings` | `engine`, `model` (`gemini-embedding-2` enables multimodal embeddings), `dimensions` (`0` keeps the provider default) |
1897
+ | `images` | `engine`, `model` |
1898
+ | `videos` | `engine`, `model`, `output_dir`, `poll_interval_seconds`, `timeout_seconds` |
1899
+ | `voice` | `engine`, `tts_model`, `language` |
1900
+ | `geo_residency` | `US`, `USA`, or `United States` normalize to US routing where supported |
1754
1901
 
1755
- ### Common Model Settings
1902
+ ### Settings that stay in `.env`
1756
1903
 
1757
1904
  | Environment variable | Notes |
1758
1905
  | --------------------------- | ------------------------------------------------------------------------------------------- |
1759
- | `COMPLETIONS_MODEL_NAME` | Optional completions model override |
1760
- | `COMPLETIONS_FALLBACKS` | Optional ordered `engine:model` pairs tried after the primary fails (see Model fallback) |
1761
- | `COMPLETIONS_FALLBACK_ON` | Optional comma-separated `AiFallbackReason` values that trigger a fallback; default `unavailable,rate_limited,quota_exhausted,auth_rejected` |
1762
1906
  | `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) |
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 |
1769
- | `IMAGE_MODEL_NAME` | Optional image model override |
1770
- | `VIDEO_MODEL_NAME` | Optional video model override |
1771
- | `VIDEO_OUTPUT_DIR` | Optional local output directory for materialized video artifacts |
1772
- | `VIDEO_POLL_INTERVAL_SECONDS` | Optional default poll interval for video job waits |
1773
- | `VIDEO_TIMEOUT_SECONDS` | Optional default timeout for blocking video generation |
1774
1907
  | `BEDROCK_VIDEO_OUTPUT_S3_URI` | Required for Nova Reel unless provided per request |
1775
- | `DEFAULT_GEMINI_TTS_MODEL` | Optional Google voice model override |
1776
- | `EMBEDDING_DIMENSIONS` | Optional embeddings dimension override. Leave unset for provider defaults. |
1777
- | `AI_API_GEO_RESIDENCY` | Optional geo hint. `US`, `USA`, or `United States` normalize to US routing where supported. |
1778
1908
  | `AI_MIDDLEWARE_CONFIG_PATH` | Optional YAML config path for observability and PII middleware |
1779
1909
  | `ANTHROPIC_BASE_URL_OVERRIDE` | Optional API base-URL override for the `claude` engine (https required) |
1780
1910
  | `OPENAI_BASE_URL_OVERRIDE` | Optional API base-URL override for the OpenAI engines (https required) |
@@ -1863,15 +1993,8 @@ Required:
1863
1993
 
1864
1994
  - `OPENAI_API_KEY`
1865
1995
 
1866
- Common optional settings:
1867
-
1868
- - `OPENAI_BASE_URL`
1869
- - `COMPLETIONS_MODEL_NAME`
1870
- - `EMBEDDING_MODEL_NAME`
1871
- - `IMAGE_MODEL_NAME`
1872
- - `VIDEO_MODEL_NAME`
1873
- - `EMBEDDING_DIMENSIONS`
1874
- - `AI_API_GEO_RESIDENCY`
1996
+ Optional: `OPENAI_BASE_URL_OVERRIDE`. Models and dimensions are selected in
1997
+ `models.json`.
1875
1998
 
1876
1999
  #### Anthropic (native Claude API)
1877
2000
 
@@ -1879,9 +2002,7 @@ Required for the `claude` completions engine:
1879
2002
 
1880
2003
  - `ANTHROPIC_API_KEY`
1881
2004
 
1882
- Common optional settings:
1883
-
1884
- - `COMPLETIONS_MODEL_NAME` (defaults to `claude-opus-5`)
2005
+ The model is `completions.model` in `models.json` (default `claude-opus-5`).
1885
2006
 
1886
2007
  Claude via Amazon Bedrock (the `anthropic` engine) does not use
1887
2008
  `ANTHROPIC_API_KEY`; it authenticates with AWS credentials like the other
@@ -1899,15 +2020,8 @@ Required:
1899
2020
  - `AWS_REGION`
1900
2021
  - standard AWS credentials in environment or runtime IAM context
1901
2022
 
1902
- Common optional settings:
1903
-
1904
- - `COMPLETIONS_MODEL_NAME`
1905
- - `EMBEDDING_MODEL_NAME`
1906
- - `IMAGE_MODEL_NAME`
1907
- - `VIDEO_MODEL_NAME`
1908
- - `BEDROCK_VIDEO_OUTPUT_S3_URI`
1909
- - `EMBEDDING_DIMENSIONS`
1910
- - `AI_API_GEO_RESIDENCY`
2023
+ Optional: `BEDROCK_VIDEO_OUTPUT_S3_URI` (Nova Reel). Models and dimensions
2024
+ are selected in `models.json`.
1911
2025
 
1912
2026
  If your AWS profile uses `aws login` (a `login_session` profile), install
1913
2027
  `botocore[crt]` as well; botocore needs it to read those credentials.
@@ -1917,9 +2031,11 @@ If your AWS profile uses `aws login` (a `login_session` profile), install
1917
2031
  AWS hosts these models itself, so requests go to AWS under your AWS account
1918
2032
  and never reach DeepSeek, Alibaba, or Z.ai.
1919
2033
 
2034
+ ```json
2035
+ "completions": {"engine": "bedrock", "model": "deepseek.v3.2"}
2036
+ ```
2037
+
1920
2038
  ```dotenv
1921
- COMPLETIONS_ENGINE=bedrock
1922
- COMPLETIONS_MODEL_NAME=deepseek.v3.2
1923
2039
  AWS_REGION=us-east-1
1924
2040
  AWS_PROFILE=<your profile>
1925
2041
  ```
@@ -2001,7 +2117,7 @@ Required:
2001
2117
  Optional:
2002
2118
 
2003
2119
  - `MICROSOFT_COGNITIVE_SERVICES_ENDPOINT`
2004
- - `AI_VOICE_LANGUAGE`
2120
+ - `voice.language` in `models.json`
2005
2121
 
2006
2122
  #### ElevenLabs
2007
2123
 
@@ -2207,6 +2323,63 @@ capability.supports_org_id, capability.supports_org_name, capability.requirement
2207
2323
  # iam:ListAccountAliases; identity falls back to id-only without it")
2208
2324
  ```
2209
2325
 
2326
+ #### Per-request cost
2327
+
2328
+ A request's total cost includes every call it made: classification, failed
2329
+ attempts, fallbacks, and escalations. One routed structured-output request
2330
+ can be four provider calls: the classifier, a 529 on the first tier, a
2331
+ fallback on the same tier, and an escalation that answers. Each call still
2332
+ emits its own `ai_api_call_cost` event; the request adds one total on top.
2333
+
2334
+ Every client keeps the report for the last request it ran as the outermost
2335
+ call:
2336
+
2337
+ ```python
2338
+ result = client.send_structured_output(prompt, response_model=Summary)
2339
+ report = client.last_request_cost # AIRequestCostReport
2340
+ report.total_usd # Decimal, every priced call
2341
+ report.classifier_usd # the router's classifier call
2342
+ report.wasted_usd # billed answers nobody used
2343
+ report.billed_calls, report.failed_calls # 3 and 1 in the example above
2344
+ report.served_by # "claude:claude-opus-5"
2345
+ for item in report.calls: # AIRequestCostLineItem, in call order
2346
+ item.role, item.route_tier, item.outcome, item.usd_cost, item.served
2347
+ ```
2348
+
2349
+ A failed attempt is a line item with no cost and no tokens; it still counts
2350
+ as a call. A failed attempt the provider billed, a structured answer that
2351
+ could not be parsed or validated (`StructuredOutputValidationError`) or a
2352
+ truncated one (`StructuredResponseTokenLimitError`), keeps the usage the
2353
+ exception carries, so the report prices it and counts it in `billed_calls`
2354
+ and `wasted_usd`; its per-call cost event carries `outcome: "error"`. A model
2355
+ with no registry pricing counts as billed but unpriced
2356
+ (`unpriced_calls`), so `total_usd` understates the request by its cost.
2357
+ The ledger records calls whether or not the observability middleware is on.
2358
+
2359
+ To roll several library calls into one logical request, such as a workflow
2360
+ step, open the scope yourself. Nested scopes join the outer one, and the
2361
+ clients inside keep no report of their own:
2362
+
2363
+ ```python
2364
+ from ai_api_unified import request_cost_scope
2365
+
2366
+ with request_cost_scope("summarize-contract") as scope:
2367
+ router.send_structured_output(prompt, response_model=Summary)
2368
+ router.send_prompt("One-line title for this summary.")
2369
+ scope.report.total_usd # both calls, every attempt behind them
2370
+ ```
2371
+
2372
+ When `emit_cost` is on, the outermost scope emits one `ai_api_request_cost`
2373
+ event on the cost topic as it closes (normally or on an exception):
2374
+ `request_id`, `operation`, `status`, `caller_id`, `session_id`,
2375
+ `workflow_id`, `served_by`, `served_by_tier`, `served_by_method`,
2376
+ `total_usd`, `classifier_usd`, `wasted_usd`, `billed_calls`,
2377
+ `failed_calls`, `unpriced_calls`, `elapsed_ms`, and `calls` (a compact list
2378
+ with `call_id`, `role`, `model`, `outcome`, `usd_cost`, `served`). The
2379
+ `request_id` is the router's `route_id` when routed, and every per-call
2380
+ event under the request carries it as `tag_request_id`, so the per-call
2381
+ events and the total join on one key.
2382
+
2210
2383
  ### PII Redaction
2211
2384
 
2212
2385
  PII redaction is optional and requires one of:
@@ -2365,12 +2538,12 @@ git push origin v<version>
2365
2538
 
2366
2539
  ## Troubleshooting
2367
2540
 
2368
- - `COMPLETIONS_ENGINE must be configured explicitly` or similar: set the required engine selector for that capability.
2541
+ - An engine `must be configured explicitly` error: set that capability's `engine` in `models.json`.
2369
2542
  - `AiProviderDependencyUnavailableError`: install the extra for the selected provider.
2370
2543
  - Google auth errors: the OSS default is `GOOGLE_AUTH_METHOD=api_key`. If you switch to `service_account`, point `GOOGLE_APPLICATION_CREDENTIALS` at a valid local JSON credential file. Set `GOOGLE_PROJECT_ID` and `GOOGLE_LOCATION` when required.
2371
- - Unexpected embeddings dimensions: leave `EMBEDDING_DIMENSIONS` unset unless you intentionally want a non-default size.
2544
+ - Unexpected embeddings dimensions: leave `embeddings.dimensions` at `0` unless you intentionally want a non-default size.
2372
2545
  - Google image generation or TTS failures in live tests often come from account or service state: a disabled cloud API, or missing paid-plan access. Check those before suspecting a library bug.
2373
- - `AI_API_GEO_RESIDENCY=US` is a best-effort routing hint. Only providers that expose regional routing controls can honor it directly.
2546
+ - `geo_residency: "US"` is a best-effort routing hint. Only providers that expose regional routing controls can honor it directly.
2374
2547
 
2375
2548
  ## License
2376
2549