ai_api_unified 3.2.0__tar.gz → 3.5.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.5.0}/PKG-INFO +286 -143
  2. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/README.md +285 -142
  3. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/pyproject.toml +1 -1
  4. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/__init__.py +21 -1
  5. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/__version__.py +1 -1
  6. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/ai_base.py +513 -73
  7. ai_api_unified-3.5.0/src/ai_api_unified/ai_completions_exceptions.py +107 -0
  8. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/ai_factory.py +29 -10
  9. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/ai_models_config.py +36 -0
  10. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/ai_provider_exceptions.py +8 -0
  11. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/completions/ai_anthropic_completions.py +92 -25
  12. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/completions/ai_bedrock_completions.py +152 -24
  13. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/completions/ai_fallback_completions.py +83 -2
  14. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/completions/ai_google_gemini_capabilities.py +2 -0
  15. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/completions/ai_google_gemini_completions.py +26 -23
  16. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/completions/ai_openai_completions.py +34 -23
  17. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/completions/ai_openai_responses_completions.py +22 -0
  18. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/completions/ai_router_completions.py +252 -172
  19. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/middleware/__init__.py +12 -0
  20. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/middleware/observability.py +112 -29
  21. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/middleware/observability_runtime.py +172 -51
  22. ai_api_unified-3.5.0/src/ai_api_unified/middleware/request_cost.py +736 -0
  23. ai_api_unified-3.5.0/src/ai_api_unified/thinking.py +545 -0
  24. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/util/env_settings.py +53 -0
  25. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/voice/ai_voice_factory.py +1 -1
  26. ai_api_unified-3.2.0/src/ai_api_unified/ai_completions_exceptions.py +0 -51
  27. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/LICENSE +0 -0
  28. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/ai_anthropic_base.py +0 -0
  29. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/ai_bedrock_base.py +0 -0
  30. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/ai_google_base.py +0 -0
  31. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/ai_openai_base.py +0 -0
  32. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/ai_provider_loader.py +0 -0
  33. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/ai_provider_registry.py +0 -0
  34. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/classification/__init__.py +0 -0
  35. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/classification/ai_openai_decisions_classifier.py +0 -0
  36. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/classification/ai_structured_output_classifier.py +0 -0
  37. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/completions/__init__.py +0 -0
  38. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/completions/ai_openai_compatible_completions.py +0 -0
  39. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/embeddings/__init__.py +0 -0
  40. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/embeddings/ai_google_gemini_embeddings.py +0 -0
  41. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/embeddings/ai_openai_embeddings.py +0 -0
  42. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/embeddings/ai_titan_embeddings.py +0 -0
  43. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/embeddings/ai_voyage_embeddings.py +0 -0
  44. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/images/__init__.py +0 -0
  45. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/images/ai_bedrock_images.py +0 -0
  46. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/images/ai_google_gemini_images.py +0 -0
  47. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/images/ai_openai_images.py +0 -0
  48. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/middleware/impl/__init__.py +0 -0
  49. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/middleware/impl/_presidio_redactor.py +0 -0
  50. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/middleware/impl/base_redactor.py +0 -0
  51. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/middleware/impl/custom_recognizer_factory.py +0 -0
  52. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/middleware/impl/noop_redactor.py +0 -0
  53. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/middleware/impl/presidio_log_control.py +0 -0
  54. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/middleware/middleware.py +0 -0
  55. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/middleware/middleware_config.py +0 -0
  56. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/middleware/pii_redactor.py +0 -0
  57. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/middleware/redaction_exceptions.py +0 -0
  58. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/pricing/__init__.py +0 -0
  59. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/pricing/model_pricing.py +0 -0
  60. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/pricing/pricing_registry.py +0 -0
  61. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/routing/__init__.py +0 -0
  62. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/routing/router_config.py +0 -0
  63. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/util/__init__.py +0 -0
  64. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/util/_lazy_pydub.py +0 -0
  65. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/util/ai_model_checker.py +0 -0
  66. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/util/ai_model_probes.py +0 -0
  67. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/util/package_metadata_validation.py +0 -0
  68. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/util/utils.py +0 -0
  69. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/videos/__init__.py +0 -0
  70. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/videos/ai_bedrock_videos.py +0 -0
  71. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/videos/ai_google_gemini_videos.py +0 -0
  72. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/videos/ai_openai_videos.py +0 -0
  73. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/videos/frame_helpers.py +0 -0
  74. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/voice/__init__.py +0 -0
  75. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/voice/ai_voice_azure.py +0 -0
  76. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/voice/ai_voice_base.py +0 -0
  77. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/voice/ai_voice_elevenlabs.py +0 -0
  78. {ai_api_unified-3.2.0 → ai_api_unified-3.5.0}/src/ai_api_unified/voice/ai_voice_google.py +0 -0
  79. {ai_api_unified-3.2.0 → ai_api_unified-3.5.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.5.0}/src/ai_api_unified/voice/ai_voice_openai.py +0 -0
  81. {ai_api_unified-3.2.0 → ai_api_unified-3.5.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.5.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.5.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:
1242
-
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
1334
+ From `models.json`, which is the usual way:
1250
1335
 
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,7 +1364,7 @@ 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
@@ -1310,11 +1404,12 @@ Rules for the chain:
1310
1404
  | `AUTH_REJECTED` | yes | 401/403, a revoked or invalid API key, a disabled organization, expired AWS credentials |
1311
1405
  | `MODEL_UNAVAILABLE` | no | 404, an unknown or retired model id, a model not offered in the region |
1312
1406
  | `TIMEOUT` | no | the call's `request_timeout_seconds` passed with no answer |
1313
- | `None` | never | validation errors, a safety refusal |
1407
+ | `INVALID_OUTPUT` | no | a structured answer that is not valid JSON, not a JSON object, or fails the response model (`StructuredOutputValidationError`) |
1408
+ | `None` | never | a safety refusal, request validation before any call |
1314
1409
 
1315
1410
  `MODEL_UNAVAILABLE` is off by default because an unknown model id usually
1316
1411
  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
1412
+ with `fallback_on` when you want an unknown or
1318
1413
  retired model to fail over.
1319
1414
 
1320
1415
  `TIMEOUT` is off by default because the deadline is yours, and a failover
@@ -1336,6 +1431,15 @@ often a configuration error too; the `failover` and `served_by_fallback` log
1336
1431
  events (below) record it while traffic keeps flowing. Remove
1337
1432
  `auth_rejected` from `fallback_on` to make it fail loudly instead.
1338
1433
 
1434
+ `INVALID_OUTPUT` is off by default because the provider billed the bad answer, and
1435
+ failing over buys a second billed answer on another model: a spending
1436
+ decision. The error is `StructuredOutputValidationError`, a `ValueError` (so
1437
+ existing handlers keep working) that carries the discarded answer's `usage`,
1438
+ `raw_text`, and `finish_reason`. Turn the reason on to let the chain try the
1439
+ next model, and the router escalate, on bad output; each discarded answer
1440
+ then shows on the request's cost report as a wasted line item (see
1441
+ "Per-request cost").
1442
+
1339
1443
  A safety refusal is never a trigger: retrying a
1340
1444
  refused request on another model is a policy decision, not a reliability one,
1341
1445
  and it belongs in your code if you want it.
@@ -1443,8 +1547,8 @@ across the chain.
1443
1547
 
1444
1548
  | Setting | Meaning |
1445
1549
  | ------- | ------- |
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`. |
1550
+ | `completions.fallbacks` (`models.json`) | Ordered `engine:model` pairs tried after the primary fails. Empty or absent means no chain. |
1551
+ | `completions.fallback_on` | `AiFallbackReason` values (a list, or comma-separated) that trigger a move. Default `unavailable,rate_limited,quota_exhausted,auth_rejected`. |
1448
1552
  | `fallbacks=` (factory argument) | Same as the setting, as `(engine, model)` tuples or `AIFallbackCandidate` objects; overrides the setting. `[]` turns the chain off. |
1449
1553
  | `fallback_on=` (factory argument) | Same as the setting, as reasons or their values; overrides the setting. |
1450
1554
 
@@ -1502,9 +1606,8 @@ can read.
1502
1606
  }
1503
1607
  ```
1504
1608
 
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:
1609
+ Put it in the `router` section of `models.json`, keep `completions.engine`
1610
+ and `completions.model` as they are, and call the factory as usual:
1508
1611
 
1509
1612
  ```python
1510
1613
  client = AIFactory.get_ai_completions_client() # an AiRouterCompletions
@@ -1513,17 +1616,16 @@ print(client.last_tier, client.last_decision)
1513
1616
  ```
1514
1617
 
1515
1618
  In code, pass `router=AIRouterConfig(...)` instead. The configured
1516
- completions LLM, with its `COMPLETIONS_FALLBACKS` chain, is the **default
1619
+ completions LLM, with its `completions.fallbacks` chain, is the **default
1517
1620
  route**: where calls go when no classifier is available, when the
1518
1621
  classifier fails, or when `default_tier` is unset.
1519
1622
 
1520
1623
  ### The classifier
1521
1624
 
1522
1625
  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
1626
+ order: the router's own `classifier` entry, then the top-level
1627
+ `classifier` section of `models.json`, then `openai-decisions` when
1628
+ `OPENAI_API_KEY` is set. Set either to `none` to route without a classifier; if the
1527
1629
  classifier cannot be built, the router logs `classifier_unavailable` once
1528
1630
  and degrades to the default route, and never raises at startup.
1529
1631
 
@@ -1587,8 +1689,8 @@ cost by tier needs no extra work. The classifier call itself carries
1587
1689
 
1588
1690
  | Setting | Meaning |
1589
1691
  | ------- | ------- |
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`. |
1692
+ | `router` (`models.json`) | `tiers`, optional `classifier`, `default_tier`, `on_exhausted`, `fallback_on`. |
1693
+ | `classifier` (`models.json`) | The classifier the router uses when its own `classifier` entry is absent; `{"engine": "none"}` routes without one. |
1592
1694
  | `AIFactory.get_ai_completions_client(router=AIRouterConfig(...))` | Code-first configuration; overrides the setting. |
1593
1695
 
1594
1696
  Exports: `AiRouterCompletions`, `AIRouterConfig`, `AIRouterTier`,
@@ -1644,7 +1746,7 @@ the results of the most recent check.
1644
1746
 
1645
1747
  | Setting | Meaning |
1646
1748
  | ------- | ------- |
1647
- | `AI_MODEL_CHECK` | `prune` (default): drop missing models with a warning. `strict`: raise on any missing model. `off`: skip the check. |
1749
+ | `model_check.mode` (`models.json`) | `prune` (default): drop missing models with a warning. `strict`: raise on any missing model. `off`: skip the check. `model_check.timeout_seconds` bounds each probe (default 10). |
1648
1750
 
1649
1751
  A client with only a primary and no chain or router runs no check: the
1650
1752
  engine fails on its own at build.
@@ -1683,7 +1785,7 @@ the value may also be inline JSON).
1683
1785
  {
1684
1786
  "allow": ["claude:*", "google-gemini:*", "openai-decisions:*"],
1685
1787
  "deny": ["openai:gpt-4*", "bedrock"],
1686
- "completions": {"engine": "google-gemini", "model": "gemini-2.5-flash",
1788
+ "completions": {"engine": "google-gemini", "model": "gemini-2.5-flash", "thinking": "medium",
1687
1789
  "fallbacks": ["claude:claude-sonnet-5"],
1688
1790
  "fallback_on": "unavailable,rate_limited,quota_exhausted,auth_rejected"},
1689
1791
  "classifier": {"engine": "openai-decisions", "model": "gpt-6-luna"},
@@ -1705,19 +1807,17 @@ poetry run python scripts/migrate2json.py # writes ./models.json
1705
1807
  poetry run python scripts/migrate2json.py --comment-env # and comments the moved keys out of .env
1706
1808
  ```
1707
1809
 
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.
1810
+ It places each selection in its section, embeds a router file the old
1811
+ `.env` pointed at, fills structural defaults from `models_sample.json`,
1812
+ leaves the allow and deny lists empty, and never touches secrets.
1712
1813
 
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`,
1814
+ The old selection keys are deprecated. One left in `.env` or the process
1815
+ environment is still accepted and overrides the file, so a deployment does
1816
+ not break, and the library logs one warning per key naming the
1817
+ `models.json` field and the migrator command
1818
+ (`ai_config_event=deprecated_selection_key`). Precedence, highest first: an
1819
+ explicit factory argument, a deprecated environment key, the file, the
1820
+ library default. Secrets (API keys, AWS credentials), `AWS_REGION`,
1721
1821
  base-URL overrides, and `AI_MIDDLEWARE_CONFIG_PATH` stay in `.env`.
1722
1822
 
1723
1823
  ### Allow and deny lists
@@ -1738,43 +1838,43 @@ classifier, and the embeddings, image, and video selections.
1738
1838
  model, raises `ValueError` naming the pattern. Runtime never calls a
1739
1839
  denied model.
1740
1840
 
1741
- ### Required Engine Selectors
1841
+ ### Engine selectors
1842
+
1843
+ There is no implicit default provider. Set the engine for each capability
1844
+ you use in `models.json`:
1845
+
1846
+ | `models.json` field | Valid values |
1847
+ | ------------------- | ------------ |
1848
+ | `completions.engine` | `openai`, `openai-responses`, `openai-compatible`, `claude`, `google-gemini`, `bedrock`, Bedrock-routed aliases such as `nova`, `anthropic`, `llama`, `mistral`, `cohere`, `ai21`, `rerank` |
1849
+ | `embeddings.engine` | `openai`, `titan`, `google-gemini`, `voyage` |
1850
+ | `classifier.engine` | `openai-decisions`, `openai`, `openai-responses`, `openai-compatible`, `claude`, `google-gemini`, `bedrock`, `anthropic` (see Classification) |
1851
+ | `images.engine` | `openai`, `google-gemini`, `nova-canvas`, `bedrock`, `nova` |
1852
+ | `videos.engine` | `openai`, `google-gemini`, `bedrock`, `nova`, `nova-reel` |
1853
+ | `voice.engine` | `openai`, `google`, `azure`, `elevenlabs` |
1854
+
1855
+ ### Model selections
1742
1856
 
1743
- There is no implicit default provider. Set the selector for each capability
1744
- you use, in `models.json` or as a variable:
1857
+ Every section of `models.json` is documented in
1858
+ [`models_sample.json`](models_sample.json). The fields, by section:
1745
1859
 
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` |
1860
+ | Section | Fields |
1861
+ | ------- | ------ |
1862
+ | `completions` | `engine`, `model`, `fallbacks` (see Model fallback), `fallback_on`, `retry_policy`, `thinking` (see Thinking levels) |
1863
+ | `classifier` | `engine`, `model`, `base_url`, `timeout_seconds`, `instructions` |
1864
+ | `router` | `tiers`, `classifier`, `default_tier`, `on_exhausted`, `fallback_on` (see Model routing) |
1865
+ | `model_check` | `mode` (`prune`, `strict`, `off`), `timeout_seconds` (see Configured-model check) |
1866
+ | `embeddings` | `engine`, `model` (`gemini-embedding-2` enables multimodal embeddings), `dimensions` (`0` keeps the provider default) |
1867
+ | `images` | `engine`, `model` |
1868
+ | `videos` | `engine`, `model`, `output_dir`, `poll_interval_seconds`, `timeout_seconds` |
1869
+ | `voice` | `engine`, `tts_model`, `language` |
1870
+ | `geo_residency` | `US`, `USA`, or `United States` normalize to US routing where supported |
1754
1871
 
1755
- ### Common Model Settings
1872
+ ### Settings that stay in `.env`
1756
1873
 
1757
1874
  | Environment variable | Notes |
1758
1875
  | --------------------------- | ------------------------------------------------------------------------------------------- |
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
1876
  | `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
1877
  | `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
1878
  | `AI_MIDDLEWARE_CONFIG_PATH` | Optional YAML config path for observability and PII middleware |
1779
1879
  | `ANTHROPIC_BASE_URL_OVERRIDE` | Optional API base-URL override for the `claude` engine (https required) |
1780
1880
  | `OPENAI_BASE_URL_OVERRIDE` | Optional API base-URL override for the OpenAI engines (https required) |
@@ -1863,15 +1963,8 @@ Required:
1863
1963
 
1864
1964
  - `OPENAI_API_KEY`
1865
1965
 
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`
1966
+ Optional: `OPENAI_BASE_URL_OVERRIDE`. Models and dimensions are selected in
1967
+ `models.json`.
1875
1968
 
1876
1969
  #### Anthropic (native Claude API)
1877
1970
 
@@ -1879,9 +1972,7 @@ Required for the `claude` completions engine:
1879
1972
 
1880
1973
  - `ANTHROPIC_API_KEY`
1881
1974
 
1882
- Common optional settings:
1883
-
1884
- - `COMPLETIONS_MODEL_NAME` (defaults to `claude-opus-5`)
1975
+ The model is `completions.model` in `models.json` (default `claude-opus-5`).
1885
1976
 
1886
1977
  Claude via Amazon Bedrock (the `anthropic` engine) does not use
1887
1978
  `ANTHROPIC_API_KEY`; it authenticates with AWS credentials like the other
@@ -1899,15 +1990,8 @@ Required:
1899
1990
  - `AWS_REGION`
1900
1991
  - standard AWS credentials in environment or runtime IAM context
1901
1992
 
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`
1993
+ Optional: `BEDROCK_VIDEO_OUTPUT_S3_URI` (Nova Reel). Models and dimensions
1994
+ are selected in `models.json`.
1911
1995
 
1912
1996
  If your AWS profile uses `aws login` (a `login_session` profile), install
1913
1997
  `botocore[crt]` as well; botocore needs it to read those credentials.
@@ -1917,9 +2001,11 @@ If your AWS profile uses `aws login` (a `login_session` profile), install
1917
2001
  AWS hosts these models itself, so requests go to AWS under your AWS account
1918
2002
  and never reach DeepSeek, Alibaba, or Z.ai.
1919
2003
 
2004
+ ```json
2005
+ "completions": {"engine": "bedrock", "model": "deepseek.v3.2"}
2006
+ ```
2007
+
1920
2008
  ```dotenv
1921
- COMPLETIONS_ENGINE=bedrock
1922
- COMPLETIONS_MODEL_NAME=deepseek.v3.2
1923
2009
  AWS_REGION=us-east-1
1924
2010
  AWS_PROFILE=<your profile>
1925
2011
  ```
@@ -2001,7 +2087,7 @@ Required:
2001
2087
  Optional:
2002
2088
 
2003
2089
  - `MICROSOFT_COGNITIVE_SERVICES_ENDPOINT`
2004
- - `AI_VOICE_LANGUAGE`
2090
+ - `voice.language` in `models.json`
2005
2091
 
2006
2092
  #### ElevenLabs
2007
2093
 
@@ -2207,6 +2293,63 @@ capability.supports_org_id, capability.supports_org_name, capability.requirement
2207
2293
  # iam:ListAccountAliases; identity falls back to id-only without it")
2208
2294
  ```
2209
2295
 
2296
+ #### Per-request cost
2297
+
2298
+ A request's total cost includes every call it made: classification, failed
2299
+ attempts, fallbacks, and escalations. One routed structured-output request
2300
+ can be four provider calls: the classifier, a 529 on the first tier, a
2301
+ fallback on the same tier, and an escalation that answers. Each call still
2302
+ emits its own `ai_api_call_cost` event; the request adds one total on top.
2303
+
2304
+ Every client keeps the report for the last request it ran as the outermost
2305
+ call:
2306
+
2307
+ ```python
2308
+ result = client.send_structured_output(prompt, response_model=Summary)
2309
+ report = client.last_request_cost # AIRequestCostReport
2310
+ report.total_usd # Decimal, every priced call
2311
+ report.classifier_usd # the router's classifier call
2312
+ report.wasted_usd # billed answers nobody used
2313
+ report.billed_calls, report.failed_calls # 3 and 1 in the example above
2314
+ report.served_by # "claude:claude-opus-5"
2315
+ for item in report.calls: # AIRequestCostLineItem, in call order
2316
+ item.role, item.route_tier, item.outcome, item.usd_cost, item.served
2317
+ ```
2318
+
2319
+ A failed attempt is a line item with no cost and no tokens; it still counts
2320
+ as a call. A failed attempt the provider billed, a structured answer that
2321
+ could not be parsed or validated (`StructuredOutputValidationError`) or a
2322
+ truncated one (`StructuredResponseTokenLimitError`), keeps the usage the
2323
+ exception carries, so the report prices it and counts it in `billed_calls`
2324
+ and `wasted_usd`; its per-call cost event carries `outcome: "error"`. A model
2325
+ with no registry pricing counts as billed but unpriced
2326
+ (`unpriced_calls`), so `total_usd` understates the request by its cost.
2327
+ The ledger records calls whether or not the observability middleware is on.
2328
+
2329
+ To roll several library calls into one logical request, such as a workflow
2330
+ step, open the scope yourself. Nested scopes join the outer one, and the
2331
+ clients inside keep no report of their own:
2332
+
2333
+ ```python
2334
+ from ai_api_unified import request_cost_scope
2335
+
2336
+ with request_cost_scope("summarize-contract") as scope:
2337
+ router.send_structured_output(prompt, response_model=Summary)
2338
+ router.send_prompt("One-line title for this summary.")
2339
+ scope.report.total_usd # both calls, every attempt behind them
2340
+ ```
2341
+
2342
+ When `emit_cost` is on, the outermost scope emits one `ai_api_request_cost`
2343
+ event on the cost topic as it closes (normally or on an exception):
2344
+ `request_id`, `operation`, `status`, `caller_id`, `session_id`,
2345
+ `workflow_id`, `served_by`, `served_by_tier`, `served_by_method`,
2346
+ `total_usd`, `classifier_usd`, `wasted_usd`, `billed_calls`,
2347
+ `failed_calls`, `unpriced_calls`, `elapsed_ms`, and `calls` (a compact list
2348
+ with `call_id`, `role`, `model`, `outcome`, `usd_cost`, `served`). The
2349
+ `request_id` is the router's `route_id` when routed, and every per-call
2350
+ event under the request carries it as `tag_request_id`, so the per-call
2351
+ events and the total join on one key.
2352
+
2210
2353
  ### PII Redaction
2211
2354
 
2212
2355
  PII redaction is optional and requires one of:
@@ -2365,12 +2508,12 @@ git push origin v<version>
2365
2508
 
2366
2509
  ## Troubleshooting
2367
2510
 
2368
- - `COMPLETIONS_ENGINE must be configured explicitly` or similar: set the required engine selector for that capability.
2511
+ - An engine `must be configured explicitly` error: set that capability's `engine` in `models.json`.
2369
2512
  - `AiProviderDependencyUnavailableError`: install the extra for the selected provider.
2370
2513
  - 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.
2514
+ - Unexpected embeddings dimensions: leave `embeddings.dimensions` at `0` unless you intentionally want a non-default size.
2372
2515
  - 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.
2516
+ - `geo_residency: "US"` is a best-effort routing hint. Only providers that expose regional routing controls can honor it directly.
2374
2517
 
2375
2518
  ## License
2376
2519