Skip to content

Language Models API

DeterministicStopError

Bases: ValueError

An empty completion a retry cannot fix. Subclasses ValueError so existing handlers keep working.

Source code in synalinks/src/modules/language_models/language_model.py
class DeterministicStopError(ValueError):
    """An empty completion a retry cannot fix. Subclasses `ValueError` so
    existing handlers keep working."""

    def __init__(self, message, finish_reason):
        super().__init__(message)
        self.finish_reason = finish_reason

LanguageModel

Bases: Module

A language model API wrapper.

A language model is a type of AI model designed to generate, and interpret human language. It is trained on large amounts of text data to learn patterns and structures in language. Language models can perform various tasks such as text generation, translation, summarization, and answering questions.

We support providers that implement constrained structured output like Azure, Ollama or Mistral. In addition we support providers that otherwise allow to constrain the use of a specific tool like Groq or Anthropic.

For the complete list of models, please refer to the providers documentation. The provider prefixes accepted in model (the part before the /) are returned by LanguageModel.supported_providers().

Using OpenAI models

import synalinks
import os

os.environ["OPENAI_API_KEY"] = "your-api-key"

language_model = synalinks.LanguageModel(
    model="openai/gpt-4o-mini",
)

Using Groq models

import synalinks
import os

os.environ["GROQ_API_KEY"] = "your-api-key"

language_model = synalinks.LanguageModel(
    model="groq/llama3-8b-8192",
)

Using Anthropic models

import synalinks
import os

os.environ["ANTHROPIC_API_KEY"] = "your-api-key"

language_model = synalinks.LanguageModel(
    model="anthropic/claude-3-sonnet-20240229",
)

Using Mistral models

import synalinks
import os

os.environ["MISTRAL_API_KEY"] = "your-api-key"

language_model = synalinks.LanguageModel(
    model="mistral/codestral-latest",
)

Using Ollama models

import synalinks
import os

language_model = synalinks.LanguageModel(
    model="ollama/mistral",
)

Using Azure models

import synalinks
import os

os.environ["AZURE_API_KEY"] = "your-api-key"
os.environ["AZURE_API_BASE"] = "your-api-base"
os.environ["AZURE_API_VERSION"] = "your-api-version"

language_model = synalinks.LanguageModel(
    model="azure/<your_deployment_name>",
)

Using Google Gemini models

import synalinks
import os

os.environ["GEMINI_API_KEY"] = "your-api-key"

language_model = synalinks.LanguageModel(
    model="gemini/gemini-3.1-flash-lite-preview",
)

Using XAI models

import synalinks
import os

os.environ["XAI_API_KEY"] = "your-api-key"

language_model = synalinks.LanguageModel(
    model="xai/grok-code-fast-1",
)

Using Cohere models

import synalinks
import os

os.environ["COHERE_API_KEY"] = "your-api-key"

language_model = synalinks.LanguageModel(
    model="cohere/command-r-plus",
)

Using DeepSeek models

import synalinks
import os

os.environ["DEEPSEEK_API_KEY"] = "your-api-key"

language_model = synalinks.LanguageModel(
    model="deepseek/deepseek-chat",
)

Using Together AI models

import synalinks
import os

os.environ["TOGETHER_AI_API_KEY"] = "your-api-key"

language_model = synalinks.LanguageModel(
    model="together_ai/meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo",
)

Using OpenRouter models

import synalinks
import os

os.environ["OPENROUTER_API_KEY"] = "your-api-key"

language_model = synalinks.LanguageModel(
    model="openrouter/anthropic/claude-3-haiku",
)

Using AWS Bedrock models

import synalinks
import os

os.environ["AWS_ACCESS_KEY_ID"] = "your-access-key"
os.environ["AWS_SECRET_ACCESS_KEY"] = "your-secret-key"
os.environ["AWS_REGION_NAME"] = "us-east-1"

language_model = synalinks.LanguageModel(
    model="bedrock/anthropic.claude-3-sonnet-20240229-v1:0",
)

Using Doubleword models

Doubleword exposes an OpenAI-compatible API. The doubleword/ prefix is rewritten to openai/ internally and api_base is defaulted to https://api.doubleword.ai/v1, so structured outputs flow through the standard OpenAI path. Set OPENAI_API_KEY to your Doubleword API key (or pass api_base explicitly to override).

import synalinks
import os

os.environ["OPENAI_API_KEY"] = "your-doubleword-api-key"

language_model = synalinks.LanguageModel(
    model="doubleword/qwen-qwen3-5-397b-a17b-fp8-dottxt",
)

Using Mirai models

Mirai is an on-device inference engine for Apple silicon, similar to Ollama but faster (brew install mirai). Start the server first with the model registry id; it downloads the model if needed and serves an OpenAI-compatible API on http://localhost:8000/v1:

mirai server --model trymirai/Qwen3.8-27B-M

The server hosts that one model and ignores the model field of requests, so the name after mirai/ is informational. The mirai/ prefix is rewritten to hosted_vllm/ internally (same wire format, no API key) and api_base is defaulted to the local server, so structured outputs flow through the JSON schema path. Pass api_base explicitly for another host or port (--host/--port on the server side).

import synalinks

language_model = synalinks.LanguageModel(
    model="mirai/Qwen3.8-27B-M",
)

Using SGLang models

SGLang serves an OpenAI-compatible API, on http://localhost:30000/v1 by default:

python -m sglang.launch_server --model-path Qwen/Qwen3-8B

The sglang/ prefix is rewritten to hosted_vllm/ internally (same wire format) and api_base is defaulted to SGLANG_API_BASE, then to the local server, so structured outputs flow through the JSON schema path. For a server started with --api-key, set HOSTED_VLLM_API_KEY.

import synalinks

language_model = synalinks.LanguageModel(
    model="sglang/Qwen/Qwen3-8B",
)

Using a ChatGPT subscription (no API key)

The codex/ prefix reaches OpenAI models through a ChatGPT subscription login instead of an API key. It does not go through LiteLLM: use synalinks.OAuthLanguageModel, which sends the requests through the locally signed-in Codex CLI (see its documentation for the setup and limitations).

import synalinks

language_model = synalinks.OAuthLanguageModel(
    model="codex/gpt-6.1-sol",
)

To cascade models in case there is anything wrong with the model provider (hence making your pipelines more robust). Use the fallback argument like in this example:

import synalinks
import os

os.environ["GEMINI_API_KEY"] = "your-api-key"
os.environ["ANTHROPIC_API_KEY"] = "your-api-key"

language_model = synalinks.LanguageModel(
    model="anthropic/claude-3-sonnet-20240229",
    fallback=synalinks.LanguageModel(
        model="gemini/gemini-3.1-flash-lite-preview",
    )
)

Note: Obviously, use an .env file and .gitignore to avoid putting your API keys in the code or a config file that can lead to leackage when pushing it into repositories.

Controlling reasoning effort

Reasoning ("thinking") models can be steered with the reasoning_effort parameter, passed either as a default in the constructor or per call. It accepts:

  • "none" (the default): send nothing, leaving the model at its provider default reasoning behavior.
  • "disable": actively turn native reasoning off. This only has an effect for providers that reason by default, i.e. ollama's thinking models (qwen3, deepseek-r1, ...), where it maps to ollama's think=False toggle (safely ignored by non-thinking ollama models), and the ChatGPT backend (codex/), where it sends the lowest effort every model accepts ("low": most of them cannot turn reasoning off). Opt-in providers (OpenAI, Anthropic, Gemini, ...) reason only when explicitly enabled, so there is nothing to send and this value is a no-op for them.
  • any other value (e.g. "low", "medium", "high"): forwarded to the provider as the reasoning effort, but only when the model supports reasoning (otherwise it is silently dropped).
import synalinks

language_model = synalinks.LanguageModel(
    model="openai/o3-mini",
    reasoning_effort="medium",
)

Parameters:

Name Type Description Default
model str

The model to use.

None
api_base str

Optional. The endpoint to use.

None
timeout int

Optional. The timeout value in seconds (Default to 600).

600
retry int

Optional. The number of retry (default to 5).

5
retry_max_wait int

Optional. Max seconds to wait between retries when a rate-limit Retry-After header is honored (default to 60).

60
fallback LanguageModel

Optional. The language model to fallback if anything is wrong.

None
caching bool

Optional. Enable caching of LM calls (Default to False).

False
cache_dir str

Optional. Directory for a persistent on-disk response cache. When set, every successful response is saved as a JSON file keyed by the full request (model, messages, schema and generation parameters), and identical requests are answered from disk without calling the provider, including across runs and processes. Streamed responses are cached once the stream is fully consumed (an abandoned stream is not cached) and a cache hit on a streaming call is replayed as a stream. (Default to None, disabled).

None
name str

Optional. The name of the module.

None
description str

Optional. The description of the module.

None
hooks list

Optional. Hooks to attach to this module's calls.

None
**default_kwargs object

Optional. Default generation parameters (e.g. temperature, top_p, top_k, max_tokens, reasoning_effort) forwarded to every call. Per-call kwargs override these. See "Controlling reasoning effort" above for the reasoning_effort values and their semantics.

{}
Source code in synalinks/src/modules/language_models/language_model.py
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
@synalinks_export(
    [
        "synalinks.LanguageModel",
        "synalinks.language_models.LanguageModel",
    ]
)
class LanguageModel(Module):
    """A language model API wrapper.

    A language model is a type of AI model designed to generate, and interpret human
    language. It is trained on large amounts of text data to learn patterns and
    structures in language. Language models can perform various tasks such as text
    generation, translation, summarization, and answering questions.

    We support providers that implement *constrained structured output*
    like Azure, Ollama or Mistral. In addition we support providers that otherwise
    allow to constrain the use of a specific tool like Groq or Anthropic.

    For the complete list of models, please refer to the providers documentation.
    The provider prefixes accepted in `model` (the part before the `/`) are
    returned by `LanguageModel.supported_providers()`.

    **Using OpenAI models**

    ```python
    import synalinks
    import os

    os.environ["OPENAI_API_KEY"] = "your-api-key"

    language_model = synalinks.LanguageModel(
        model="openai/gpt-4o-mini",
    )
    ```

    **Using Groq models**

    ```python
    import synalinks
    import os

    os.environ["GROQ_API_KEY"] = "your-api-key"

    language_model = synalinks.LanguageModel(
        model="groq/llama3-8b-8192",
    )
    ```

    **Using Anthropic models**

    ```python
    import synalinks
    import os

    os.environ["ANTHROPIC_API_KEY"] = "your-api-key"

    language_model = synalinks.LanguageModel(
        model="anthropic/claude-3-sonnet-20240229",
    )
    ```

    **Using Mistral models**

    ```python
    import synalinks
    import os

    os.environ["MISTRAL_API_KEY"] = "your-api-key"

    language_model = synalinks.LanguageModel(
        model="mistral/codestral-latest",
    )
    ```

    **Using Ollama models**

    ```python
    import synalinks
    import os

    language_model = synalinks.LanguageModel(
        model="ollama/mistral",
    )
    ```

    **Using Azure models**

    ```python
    import synalinks
    import os

    os.environ["AZURE_API_KEY"] = "your-api-key"
    os.environ["AZURE_API_BASE"] = "your-api-base"
    os.environ["AZURE_API_VERSION"] = "your-api-version"

    language_model = synalinks.LanguageModel(
        model="azure/<your_deployment_name>",
    )
    ```

    **Using Google Gemini models**

    ```python
    import synalinks
    import os

    os.environ["GEMINI_API_KEY"] = "your-api-key"

    language_model = synalinks.LanguageModel(
        model="gemini/gemini-3.1-flash-lite-preview",
    )
    ```

    **Using XAI models**

    ```python
    import synalinks
    import os

    os.environ["XAI_API_KEY"] = "your-api-key"

    language_model = synalinks.LanguageModel(
        model="xai/grok-code-fast-1",
    )
    ```

    **Using Cohere models**

    ```python
    import synalinks
    import os

    os.environ["COHERE_API_KEY"] = "your-api-key"

    language_model = synalinks.LanguageModel(
        model="cohere/command-r-plus",
    )
    ```

    **Using DeepSeek models**

    ```python
    import synalinks
    import os

    os.environ["DEEPSEEK_API_KEY"] = "your-api-key"

    language_model = synalinks.LanguageModel(
        model="deepseek/deepseek-chat",
    )
    ```

    **Using Together AI models**

    ```python
    import synalinks
    import os

    os.environ["TOGETHER_AI_API_KEY"] = "your-api-key"

    language_model = synalinks.LanguageModel(
        model="together_ai/meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo",
    )
    ```

    **Using OpenRouter models**

    ```python
    import synalinks
    import os

    os.environ["OPENROUTER_API_KEY"] = "your-api-key"

    language_model = synalinks.LanguageModel(
        model="openrouter/anthropic/claude-3-haiku",
    )
    ```

    **Using AWS Bedrock models**

    ```python
    import synalinks
    import os

    os.environ["AWS_ACCESS_KEY_ID"] = "your-access-key"
    os.environ["AWS_SECRET_ACCESS_KEY"] = "your-secret-key"
    os.environ["AWS_REGION_NAME"] = "us-east-1"

    language_model = synalinks.LanguageModel(
        model="bedrock/anthropic.claude-3-sonnet-20240229-v1:0",
    )
    ```

    **Using Doubleword models**

    Doubleword exposes an OpenAI-compatible API. The `doubleword/`
    prefix is rewritten to `openai/` internally and `api_base` is
    defaulted to `https://api.doubleword.ai/v1`, so structured outputs
    flow through the standard OpenAI path. Set `OPENAI_API_KEY` to your
    Doubleword API key (or pass `api_base` explicitly to override).

    ```python
    import synalinks
    import os

    os.environ["OPENAI_API_KEY"] = "your-doubleword-api-key"

    language_model = synalinks.LanguageModel(
        model="doubleword/qwen-qwen3-5-397b-a17b-fp8-dottxt",
    )
    ```

    **Using Mirai models**

    Mirai is an on-device inference engine for Apple silicon, similar to
    Ollama but faster (`brew install mirai`). Start the server first with the
    model registry id; it downloads the model if needed and serves an
    OpenAI-compatible API on `http://localhost:8000/v1`:

    ```bash
    mirai server --model trymirai/Qwen3.8-27B-M
    ```

    The server hosts that one model and ignores the `model` field of
    requests, so the name after `mirai/` is informational. The `mirai/`
    prefix is rewritten to `hosted_vllm/` internally (same wire format, no
    API key) and `api_base` is defaulted to the local server, so structured
    outputs flow through the JSON schema path. Pass `api_base` explicitly
    for another host or port (`--host`/`--port` on the server side).

    ```python
    import synalinks

    language_model = synalinks.LanguageModel(
        model="mirai/Qwen3.8-27B-M",
    )
    ```

    **Using SGLang models**

    SGLang serves an OpenAI-compatible API, on `http://localhost:30000/v1` by
    default:

    ```bash
    python -m sglang.launch_server --model-path Qwen/Qwen3-8B
    ```

    The `sglang/` prefix is rewritten to `hosted_vllm/` internally (same wire
    format) and `api_base` is defaulted to `SGLANG_API_BASE`, then to the local
    server, so structured outputs flow through the JSON schema path. For a
    server started with `--api-key`, set `HOSTED_VLLM_API_KEY`.

    ```python
    import synalinks

    language_model = synalinks.LanguageModel(
        model="sglang/Qwen/Qwen3-8B",
    )
    ```

    **Using a ChatGPT subscription (no API key)**

    The `codex/` prefix reaches OpenAI models through a ChatGPT subscription
    login instead of an API key. It does not go through LiteLLM: use
    `synalinks.OAuthLanguageModel`, which sends the requests through the
    locally signed-in Codex CLI (see its documentation for the setup and
    limitations).

    ```python
    import synalinks

    language_model = synalinks.OAuthLanguageModel(
        model="codex/gpt-6.1-sol",
    )
    ```

    To cascade models in case there is anything wrong with
    the model provider (hence making your pipelines more robust).
    Use the `fallback` argument like in this example:

    ```python
    import synalinks
    import os

    os.environ["GEMINI_API_KEY"] = "your-api-key"
    os.environ["ANTHROPIC_API_KEY"] = "your-api-key"

    language_model = synalinks.LanguageModel(
        model="anthropic/claude-3-sonnet-20240229",
        fallback=synalinks.LanguageModel(
            model="gemini/gemini-3.1-flash-lite-preview",
        )
    )
    ```

    **Note**: Obviously, use an `.env` file and `.gitignore` to avoid
    putting your API keys in the code or a config file that can lead to
    leackage when pushing it into repositories.

    **Controlling reasoning effort**

    Reasoning ("thinking") models can be steered with the `reasoning_effort`
    parameter, passed either as a default in the constructor or per call. It
    accepts:

    - `"none"` (the default): send nothing, leaving the model at its provider
      default reasoning behavior.
    - `"disable"`: actively turn native reasoning *off*. This only has an
      effect for providers that reason by default, i.e. ollama's thinking
      models (`qwen3`, `deepseek-r1`, ...), where it maps to ollama's
      `think=False` toggle (safely ignored by non-thinking ollama models), and
      the ChatGPT backend (`codex/`), where it sends the lowest effort every
      model accepts (`"low"`: most of them cannot turn reasoning off).
      Opt-in providers (OpenAI, Anthropic, Gemini, ...) reason only when
      explicitly enabled, so there is nothing to send and this value is a
      no-op for them.
    - any other value (e.g. `"low"`, `"medium"`, `"high"`): forwarded to the
      provider as the reasoning effort, but only when the model supports
      reasoning (otherwise it is silently dropped).

    ```python
    import synalinks

    language_model = synalinks.LanguageModel(
        model="openai/o3-mini",
        reasoning_effort="medium",
    )
    ```

    Args:
        model (str): The model to use.
        api_base (str): Optional. The endpoint to use.
        timeout (int): Optional. The timeout value in seconds (Default to 600).
        retry (int): Optional. The number of retry (default to 5).
        retry_max_wait (int): Optional. Max seconds to wait between retries when a
            rate-limit `Retry-After` header is honored (default to 60).
        fallback (LanguageModel): Optional. The language model to fallback
            if anything is wrong.
        caching (bool): Optional. Enable caching of LM calls (Default to False).
        cache_dir (str): Optional. Directory for a persistent on-disk response
            cache. When set, every successful response is saved as a JSON
            file keyed by the full request (model, messages, schema and
            generation parameters), and identical requests are answered from
            disk without calling the provider, including across runs and
            processes. Streamed responses are cached once the stream is fully
            consumed (an abandoned stream is not cached) and a cache hit on a
            streaming call is replayed as a stream. (Default to None,
            disabled).
        name (str): Optional. The name of the module.
        description (str): Optional. The description of the module.
        hooks (list): Optional. Hooks to attach to this module's calls.
        **default_kwargs: Optional. Default generation parameters (e.g.
            `temperature`, `top_p`, `top_k`, `max_tokens`, `reasoning_effort`)
            forwarded to every call. Per-call kwargs override these. See
            "Controlling reasoning effort" above for the `reasoning_effort`
            values and their semantics.
    """

    def __init__(
        self,
        model=None,
        api_base=None,
        timeout=600,
        retry=5,
        retry_max_wait=60,
        fallback=None,
        caching=False,
        cache_dir=None,
        name=None,
        description=None,
        hooks=None,
        **default_kwargs: object,
    ):
        super().__init__(
            trainable=False,
            name=name,
            description=description,
            hooks=hooks,
        )
        # `messages` may be passed as a Pydantic DataModel; the strict
        # JsonDataModel guard would otherwise reject it.
        self._allow_non_json_data_model_positional_args = True
        if model is None:
            raise ValueError("You need to set the `model` argument for any LanguageModel")
        model_provider = model.split("/")[0]
        if model_provider == "ollama":
            # Switch from `ollama` to `ollama_chat`
            # because it have better performance due to the chat prompts
            model = model.replace("ollama", "ollama_chat")
        if model_provider == "vllm":
            model = model.replace("vllm", "hosted_vllm")
        if model_provider == "doubleword":
            # Doubleword is OpenAI-compatible (strict JSON schema + same
            # request/response shape); route via litellm's `openai`
            # provider with the Doubleword endpoint as `api_base`.
            model = model.replace("doubleword", "openai", 1)
            if not api_base:
                api_base = "https://api.doubleword.ai/v1"
        if model_provider == "mirai":
            # Mirai serves a local OpenAI-compatible API without an API
            # key; `hosted_vllm` speaks that wire format and never asks
            # for one.
            model = model.replace("mirai", "hosted_vllm", 1)
            if not api_base:
                api_base = "http://localhost:8000/v1"
        if model_provider == "sglang":
            # SGLang serves the same OpenAI-compatible API as vLLM.
            model = model.replace("sglang", "hosted_vllm", 1)
            if not api_base:
                api_base = os.environ.get("SGLANG_API_BASE", "http://localhost:30000/v1")
        self.model = model
        if fallback is not None:
            # Lazy import: `get` lives in the package __init__ which imports
            # this file at load time.
            from synalinks.src.modules.language_models import get as _get_lm

            fallback = _get_lm(fallback)
        self.fallback = fallback
        if self.model.startswith("ollama") and not api_base:
            self.api_base = "http://localhost:11434"
        else:
            self.api_base = api_base
        if self.model.startswith("hosted_vllm") and not api_base:
            self.api_base = os.environ.get(
                "HOSTED_VLLM_API_BASE", "http://localhost:8000"
            )
        self.timeout = timeout
        self.retry = retry
        self.retry_max_wait = retry_max_wait
        self.caching = caching
        self.cache_dir = cache_dir
        self._file_cache = FileCache(cache_dir) if cache_dir else None
        self.default_kwargs = default_kwargs
        self.cumulated_cost = 0.0
        self.last_call_cost = 0.0
        # All-time counters across every LM call (training + inference).
        # Useful for raw debugging; operational metrics use the
        # inference-scoped counters below instead.
        self.cumulated_calls = 0
        self.cumulated_prompt_tokens = 0
        self.cumulated_completion_tokens = 0
        self.cumulated_tokens = 0
        self.cumulated_elapsed_s = 0.0
        self.cumulated_cached_tokens = 0
        self.cumulated_cache_creation_tokens = 0
        self.cumulated_reasoning_tokens = 0
        # Streaming latency counters: number of streamed calls and the summed
        # time-to-first-token / time-to-last-token (seconds, measured from the
        # provider request start). Averaged by the streaming metrics. Only
        # populated for `streaming=True` calls.
        self.cumulated_streaming_calls = 0
        self.cumulated_streaming_ttft_s = 0.0
        self.cumulated_streaming_ttlt_s = 0.0
        # Whole-trajectory time-to-first-token: for a streamed call made inside
        # an agent trajectory, the wall-clock from the (outermost) agent's start
        # to the first token of the final answer -- includes every tool-calling
        # round, unlike `streaming_ttft_s` which times the final LM call only.
        # Only populated when a `trajectory_scope` is active (see op_scope).
        self.cumulated_trajectory_calls = 0
        self.cumulated_trajectory_ttft_s = 0.0
        # Failure counters: a call that exhausts all retries (`failed_calls`)
        # and each time the `fallback` chain is invoked because of it
        # (`fallback_activations`). Successful calls bump `cumulated_calls`;
        # these are tracked separately so error rate is observable.
        self.cumulated_failed_calls = 0
        self.cumulated_fallback_activations = 0
        # Number of calls answered from the on-disk `cache_dir` cache
        # (no provider request, no token counters bumped).
        self.cumulated_cache_hits = 0
        self.cumulated_details = {}
        self.last_call_prompt_tokens = 0
        self.last_call_completion_tokens = 0
        self.last_call_tokens = 0
        self.last_call_elapsed_s = 0.0
        self.last_call_cached_tokens = 0
        self.last_call_cache_creation_tokens = 0
        self.last_call_reasoning_tokens = 0
        # Why the last completion stopped, from the response choice.
        self.last_call_finish_reason = None
        # Phase-scoped counters, populated based on `synalinks_op_scope` set
        # by the trainer: "inference" inside `predict_on_batch`, "reward"
        # inside `compute_reward`, "optimizer" inside `optimizer.optimize`.
        # Calls made outside any scope (e.g. standalone debugging) are
        # tracked only in the all-time `cumulated_*` set above.
        #
        # Tier 1 extras (first-class, drive dedicated KPI metrics):
        #   cached_tokens, cache_creation_tokens, reasoning_tokens.
        # Tier 2 long tail (multimodal split, tool use, LiteLLM overhead)
        # lives in `<phase>_cumulated_details`, a dict accumulated per call.
        for _phase in ("inference", "reward", "optimizer"):
            setattr(self, f"{_phase}_cumulated_calls", 0)
            setattr(self, f"{_phase}_cumulated_prompt_tokens", 0)
            setattr(self, f"{_phase}_cumulated_completion_tokens", 0)
            setattr(self, f"{_phase}_cumulated_tokens", 0)
            setattr(self, f"{_phase}_cumulated_elapsed_s", 0.0)
            setattr(self, f"{_phase}_cumulated_cost", 0.0)
            setattr(self, f"{_phase}_cumulated_cached_tokens", 0)
            setattr(self, f"{_phase}_cumulated_cache_creation_tokens", 0)
            setattr(self, f"{_phase}_cumulated_reasoning_tokens", 0)
            setattr(self, f"{_phase}_cumulated_failed_calls", 0)
            setattr(self, f"{_phase}_cumulated_fallback_activations", 0)
            setattr(self, f"{_phase}_cumulated_cache_hits", 0)
            setattr(self, f"{_phase}_cumulated_streaming_calls", 0)
            setattr(self, f"{_phase}_cumulated_streaming_ttft_s", 0.0)
            setattr(self, f"{_phase}_cumulated_streaming_ttlt_s", 0.0)
            setattr(self, f"{_phase}_cumulated_trajectory_calls", 0)
            setattr(self, f"{_phase}_cumulated_trajectory_ttft_s", 0.0)
            setattr(self, f"{_phase}_cumulated_details", {})
        # No state depends on the input shape, so mark built up-front and
        # skip Module's auto-build path (which would try to trace `call`).
        self.built = True

    def _record_event(self, suffix):
        """Bump an all-time + phase-scoped operational counter by 1 (used for
        `failed_calls` / `fallback_activations`). Phase follows the active
        `op_scope`, matching how successful-call counters are attributed.
        """
        op = current_op_scope()
        _accumulate(self, "", {suffix: 1}, None)
        if op is not None:
            _accumulate(self, f"{op}_", {suffix: 1}, None)

    async def call(
        self,
        messages,
        schema=None,
        tools=None,
        tool_schemas=None,
        streaming=False,
        **kwargs,
    ):
        """
        Call method to generate a response using the language model.

        Args:
            messages (dict): A formatted dict of chat messages.
            schema (dict): The target JSON schema for structed output (optional).
                If None, output a ChatMessage-like answer.
            tools (list | dict): Optional iterable or `{name: Tool}` mapping of
                `synalinks.modules.Tool` the LM may call. Mutually exclusive
                with `schema`: schema forces structured output, tools let the
                LM choose; they cannot both apply to the same call. In the
                function-calling agent pattern, the tool-call generator uses
                `tools` and the final generator uses `schema`.
            tool_schemas (list): Optional iterable of already-wire-formatted
                tool declaration dicts (OpenAI `{"type": "function", ...}`
                shape) passed through verbatim, appended after any converted
                `tools`. Use this to expose tools you already have a schema for
                without wrapping them in a `synalinks.modules.Tool`. Subject to
                the same mutual exclusivity with `schema` as `tools`.
            streaming (bool): Enable streaming (optional). Default to False.
                Can be enabled only if schema is None.
            **kwargs (keyword arguments): The additional keywords arguments
                forwarded to the LM call.
        Returns:
            (dict): The generated structured response.
        """
        if not messages:
            return None
        if schema and (tools or tool_schemas):
            raise ValueError(
                "`schema` and `tools` cannot be passed to the same LM call: "
                "schema forces structured output, while tools let the LM choose "
                "which to call. Split into two calls: typically the tool-call "
                "generator uses `tools` and the final generator uses `schema`."
            )
        # Cleared per call: a cache hit or a streamed call has no choice to
        # read it from and must not report the previous call's reason.
        self.last_call_finish_reason = None
        _CURRENT_CALL_USAGE.set(None)
        input_kwargs = copy.deepcopy(kwargs)
        # Merge instance-level defaults; per-call kwargs win.
        kwargs = {**self.default_kwargs, **kwargs}
        schema = copy.deepcopy(schema)
        provider = self.model.split("/")[0]

        # Single pass: messages → OpenAI wire shape (nested tool_call
        # envelopes, JSON-string arguments) and synalinks Tools → wire tool
        # declarations. The schema branches below may override `tools` /
        # `tool_choice` for providers that need structured-output-as-tool;
        # cache_control is applied to the system message after this.
        # `messages` here is typically a JsonDataModel from `ops.predict`;
        # wrap it in `ChatMessages` so its `before`-validator converts the
        # dict list into typed `ChatMessage` instances the converter needs.
        _typed_messages = (
            messages
            if hasattr(messages, "messages")
            else ChatMessages(messages=messages.get("messages", []))
        )
        formatted_messages = [_message_to_wire(m) for m in _typed_messages.messages]
        # Inline any deferred multimodal references (a dataset's image_url/
        # input_audio parts carrying a url/path) into base64 payloads, for this
        # batch only. Content already inlined at construction is left untouched.
        formatted_messages = await resolve_content_media(formatted_messages)
        # Media returned by tools (a sandbox plot, a `read_image`, a
        # `read_audio`): images stay inside the tool result for the models
        # that take them there (Claude's `tool_result`, Gemini 3's multimodal
        # function responses; litellm maps both), the rest goes to a
        # follow-up user message, the only role the other APIs take it in.
        media_kinds = {
            part.get("type")
            for message in formatted_messages
            if message["role"] == "tool" and isinstance(message["content"], list)
            for part in message["content"]
        }
        if media_kinds & {"image_url", "input_audio"}:
            formatted_messages = place_tool_result_media(
                formatted_messages,
                native=(
                    provider in ("anthropic", "vertex_ai", "bedrock")
                    and "claude" in self.model
                )
                or (provider in ("gemini", "vertex_ai") and "gemini-3" in self.model),
                vision="image_url" in media_kinds and self.supports_vision(),
                audio="input_audio" in media_kinds and self.supports_audio(),
            )
        if tools or tool_schemas:
            wire_tools = []
            if tools:
                if isinstance(tools, dict):
                    tools = tools.values()
                wire_tools.extend(_tool_to_wire(t) for t in tools)
            if tool_schemas:
                # Already in OpenAI wire shape; pass through verbatim.
                wire_tools.extend(tool_schemas)
            kwargs["tools"] = wire_tools

        # Handle reasoning_effort:
        #   "none"    -> send nothing; leave the model at its provider default.
        #   "disable" -> actively turn native reasoning OFF. This only needs an
        #                explicit flag where reasoning is ON by default, i.e.
        #                ollama's thinking models (qwen3, deepseek-r1, ...), which
        #                reason unless told not to. `think=False` is the ollama
        #                toggle and is safely ignored by non-thinking ollama
        #                models. The ChatGPT backend (`codex/`) also reasons by
        #                default: "disable" is handed to its transport, which
        #                knows the lowest effort the CLI accepts. Opt-in
        #                providers (OpenAI, Anthropic, Gemini, ...) reason only
        #                when enabled, so there is nothing to send.
        #   otherwise -> forward the effort to litellm when the model supports it.
        reasoning_effort = kwargs.pop("reasoning_effort", "none")
        schema_had_thinking = bool(schema) and "thinking" in (
            schema.get("properties") or {}
        )
        if reasoning_effort == "disable":
            if self.model.startswith("ollama"):
                kwargs["think"] = False
            elif self.model.startswith("codex"):
                kwargs["reasoning_effort"] = "disable"
        elif reasoning_effort != "none":
            if self.supports_reasoning():
                kwargs["reasoning_effort"] = reasoning_effort
                if schema_had_thinking and self.supports_reasoning_trace():
                    # The LM produces a native reasoning trace via
                    # `reasoning_content`; strip `thinking` from the LM
                    # schema to save tokens; we re-inject it after the call.
                    schema["properties"].pop("thinking", None)
                    required = schema.get("required")
                    if isinstance(required, list) and "thinking" in required:
                        required.remove("thinking")

        if schema:
            if self.model.startswith("cohere"):
                # Cohere's v2 chat constrains to `schema`; litellm refuses the param.
                extra_body = dict(kwargs.get("extra_body") or {})
                extra_body.setdefault(
                    "response_format", {"type": "json_object", "schema": schema}
                )
                kwargs["extra_body"] = extra_body
            elif self.model.startswith("anthropic"):
                # Use response_format for Anthropic - LiteLLM handles this correctly:
                # - For newer models (sonnet-4.5, opus-4.1): uses native output_format
                # - For older models: uses tool call with proper tool_choice handling
                #   (auto when thinking is enabled, forced otherwise)
                _set_if_unset(
                    kwargs,
                    {
                        "response_format": {
                            "type": "json_schema",
                            "json_schema": {
                                "schema": schema,
                            },
                        },
                    },
                )
            elif self.model.startswith("ollama") or self.model.startswith("mistral"):
                # Use constrained structured output for ollama/mistral
                _set_if_unset(
                    kwargs,
                    {
                        "response_format": {
                            "type": "json_schema",
                            "json_schema": {"schema": schema},
                            "strict": True,
                        },
                    },
                )
            elif (
                self.model.startswith("openai")
                or self.model.startswith("azure")
                or self.model.startswith("deepseek")
                or self.model.startswith("together_ai")
                or self.model.startswith("huggingface")
                or self.model.startswith("openrouter")
                or self.model.startswith("groq")
                or self.model.startswith("bedrock")
                or self.model.startswith("codex")
            ):
                # Use constrained structured output for openai/azure
                # plus deepseek, together_ai, huggingface (TGI and
                # Inference Providers), openrouter, groq and bedrock, which
                # honor the same payload (litellm maps it to bedrock's
                # `outputConfig.textFormat`). For a groq or bedrock model
                # without native support, litellm falls back to a forced tool
                # call itself.
                # OpenAI/Azure require the field  "additionalProperties"
                # Also OpenAI/Azure disallow the field "description" in $ref
                if "properties" in schema:
                    for prop_key, prop_value in schema["properties"].items():
                        if "$ref" in prop_value and "description" in prop_value:
                            del prop_value["description"]
                _set_if_unset(
                    kwargs,
                    {
                        "response_format": {
                            "type": "json_schema",
                            "json_schema": {
                                "name": "structured_output",
                                "strict": True,
                                "schema": schema,
                            },
                        }
                    },
                )
                if self.model.startswith("openrouter"):
                    # Else an endpoint ignoring `response_format` answers in prose.
                    provider = dict(kwargs.get("provider") or {})
                    provider.setdefault("require_parameters", True)
                    chosen = {
                        str(name).lower()
                        for key in ("order", "only")
                        for name in provider.get(key) or ()
                    }
                    ignored = list(provider.get("ignore") or ())
                    known = {str(name).lower() for name in ignored}
                    ignored += [
                        name
                        for name in OPENROUTER_IGNORED_PROVIDERS
                        if name not in known and name not in chosen
                    ]
                    if ignored:
                        provider["ignore"] = ignored
                    kwargs["provider"] = provider
            elif self.model.startswith("gemini"):
                _set_if_unset(
                    kwargs,
                    {
                        "response_format": {
                            "type": "json_schema",
                            "json_schema": {
                                "schema": schema,
                            },
                            "strict": True,
                        }
                    },
                )
            elif self.model.startswith("xai"):
                _set_if_unset(
                    kwargs,
                    {
                        "response_format": {
                            "type": "json_schema",
                            "json_schema": {
                                "schema": schema,
                            },
                            "strict": True,
                        }
                    },
                )
            elif self.model.startswith("hosted_vllm"):
                _set_if_unset(
                    kwargs,
                    {
                        "response_format": {
                            "type": "json_schema",
                            "json_schema": {
                                "name": "structured_output",
                                "schema": schema,
                            },
                            "strict": True,
                        }
                    },
                )
            else:
                provider = self.model.split("/")[0]
                raise ValueError(
                    f"LM provider '{provider}' not supported yet, please ensure that"
                    " they support constrained structured output and fill an issue."
                    f" Supported providers: {', '.join(SUPPORTED_PROVIDERS)}."
                )

        if self.api_base:
            kwargs.update(
                {
                    "api_base": self.api_base,
                }
            )
        if streaming and schema:
            streaming = False
        if streaming:
            kwargs.update({"stream": True})
        # Enable prompt caching for the system instructions
        # (that only change during training not inference)
        if provider in ("gemini", "anthropic"):
            system_message_with_cache_control = {
                **formatted_messages[0],
                "cache_control": {"type": "ephemeral"},
            }
            formatted_messages[0] = system_message_with_cache_control
        # Persistent on-disk cache: keyed by the fully-formatted request
        # (model, wire messages, schema and final kwargs), so any change to
        # prompts, tools or generation parameters yields a different entry.
        # The `stream` flag is excluded from the key: a streamed call stores
        # the same assistant ChatMessage a non-streamed schema-less call does
        # (streaming forces schema=None), so both share one entry; a hit is
        # replayed as a single-chunk stream when `streaming=True`.
        cache_key = None
        if self._file_cache is not None:
            cache_key = self._file_cache.make_key(
                {
                    "model": self.model,
                    "messages": formatted_messages,
                    "schema": schema,
                    "kwargs": {k: v for k, v in kwargs.items() if k != "stream"},
                }
            )
            if cache_key is not None:
                cached_json = self._file_cache.get(cache_key)
                if cached_json is not None:
                    self._record_event("cache_hits")
                    _CURRENT_CALL_USAGE.set({"cache_hit": True})
                    if streaming:
                        return StreamingIterator(_cached_message_to_chunks(cached_json))
                    return JsonDataModel(
                        json=cached_json,
                        schema=schema if schema else ChatMessage.get_schema(),
                        name=f"{self.name}_response",
                    )
        try:
            response = await self._call_with_retry(
                formatted_messages,
                schema,
                streaming,
                schema_had_thinking,
                **kwargs,
            )
            if cache_key is not None and response is not None:
                if streaming:
                    # Cache once the caller drains the stream; an abandoned
                    # stream is incomplete and is not cached.
                    file_cache = self._file_cache
                    response._on_complete = lambda json_instance: file_cache.set(
                        cache_key, json_instance
                    )
                else:
                    self._file_cache.set(cache_key, response.get_json())
            return response
        except Exception as e:
            warnings.warn(f"All retries failed for {self}: {e}")
            self._record_event("failed_calls")
            if self.fallback:
                self._record_event("fallback_activations")
                response = await self.fallback(
                    messages,
                    schema=schema,
                    streaming=streaming,
                    **input_kwargs,
                )
                # The fallback's usage is reported on its own span: this call
                # only reports its failure.
                _CURRENT_CALL_USAGE.set({"error": str(e), "fallback": True})
                return response
            else:
                _CURRENT_CALL_USAGE.set({"error": str(e)})
                return None

    async def _acompletion(self, formatted_messages, **kwargs):
        """Send one completion request and return the provider response.

        This is the transport seam: everything around it (message
        formatting, structured-output payload, cache, retry, fallback, usage
        accounting and response parsing) is provider-agnostic. A subclass
        that does not go through LiteLLM (e.g. `OAuthLanguageModel`)
        overrides this method and returns a LiteLLM-shaped response.

        Args:
            formatted_messages (list): The chat messages in OpenAI wire shape.
            **kwargs (keyword arguments): The request parameters.

        Returns:
            (ModelResponse): The provider response.
        """
        return await litellm.acompletion(
            model=self.model,
            messages=formatted_messages,
            timeout=self.timeout,
            caching=self.caching,
            **kwargs,
        )

    async def _call_with_retry(
        self, formatted_messages, schema, streaming, schema_had_thinking, **kwargs
    ):
        """Perform the LM call with tenacity retry logic."""
        logger = logging.getLogger(__name__)

        @retry(
            stop=stop_after_attempt(self.retry),
            # Honor a rate-limit `Retry-After` header (e.g. Azure OpenAI TPM
            # throttling); fall back to exponential backoff for other errors.
            wait=rate_limit_aware_wait(max_wait=self.retry_max_wait),
            retry=retry_if_not_exception_type(DeterministicStopError),
            before_sleep=before_sleep_log(logger, logging.WARNING),
            reraise=True,
        )
        async def _do_call():
            response_str = ""
            try:
                t0 = time.perf_counter()
                response = await self._acompletion(formatted_messages, **kwargs)
                elapsed_s = time.perf_counter() - t0
                op_scope = current_op_scope()
                response_cost = None
                if hasattr(response, "_hidden_params"):
                    if "response_cost" in response._hidden_params:
                        response_cost = response._hidden_params["response_cost"]
                        if response_cost is not None:
                            self.last_call_cost = response_cost
                # Streaming usage isn't known until the stream completes,
                # so skip counter updates in that case.
                if not streaming:
                    usage = response.get("usage") or {}
                    prompt_tokens = int(usage.get("prompt_tokens") or 0)
                    completion_tokens = int(usage.get("completion_tokens") or 0)
                    total_tokens = int(
                        usage.get("total_tokens") or (prompt_tokens + completion_tokens)
                    )
                    cached, cache_creation, reasoning, extras = _extract_lm_extras(
                        usage, response
                    )
                    self.last_call_prompt_tokens = prompt_tokens
                    self.last_call_completion_tokens = completion_tokens
                    self.last_call_tokens = total_tokens
                    self.last_call_elapsed_s = elapsed_s
                    self.last_call_cached_tokens = cached
                    self.last_call_cache_creation_tokens = cache_creation
                    self.last_call_reasoning_tokens = reasoning
                    flat_increments = {
                        "calls": 1,
                        "prompt_tokens": prompt_tokens,
                        "completion_tokens": completion_tokens,
                        "tokens": total_tokens,
                        "elapsed_s": elapsed_s,
                        "cached_tokens": cached,
                        "cache_creation_tokens": cache_creation,
                        "reasoning_tokens": reasoning,
                    }
                    if response_cost is not None:
                        flat_increments["cost"] = response_cost
                    _CURRENT_CALL_USAGE.set(
                        {
                            "input_tokens": prompt_tokens,
                            "output_tokens": completion_tokens,
                            "total_tokens": total_tokens,
                            "cache_read_input_tokens": cached,
                            "cache_creation_input_tokens": cache_creation,
                            "reasoning_tokens": reasoning,
                            "cost": response_cost,
                            "elapsed_s": elapsed_s,
                            "finish_reason": None,
                        }
                    )
                    _accumulate(self, "", flat_increments, extras)
                    if op_scope is not None:
                        _accumulate(self, f"{op_scope}_", flat_increments, extras)
                if streaming:
                    return StreamingIterator(
                        response,
                        language_model=self,
                        op_scope=op_scope,
                        request_start=t0,
                        trajectory_start=current_trajectory_start(),
                    )
                if not response.get("choices"):
                    raise ValueError(
                        "Empty response from the language model: no choices returned."
                    )
                response_message = response["choices"][0]["message"]
                finish_reason = _safe_get(response["choices"][0], "finish_reason")
                self.last_call_finish_reason = finish_reason
                if _CURRENT_CALL_USAGE.get():
                    _CURRENT_CALL_USAGE.get()["finish_reason"] = finish_reason
                wire_tool_calls = _safe_get(response_message, "tool_calls", None)
                refusal = _safe_get(response_message, "refusal")
                audio = _safe_get(response_message, "audio")
                if audio is not None and hasattr(audio, "model_dump"):
                    audio = audio.model_dump()
                if schema and wire_tool_calls:
                    # A reply carrying the structured output as a tool call
                    # (e.g. a caller's own tool payload): the JSON is the
                    # call's `arguments`, while `content` is empty or a
                    # short preamble. Read it wherever a tool call came
                    # back, whatever the provider.
                    arguments = _safe_get(
                        _safe_get(wire_tool_calls[0], "function"), "arguments", ""
                    )
                    if isinstance(arguments, (dict, list)):
                        arguments = orjson.dumps(arguments).decode()
                    response_str = arguments.strip() if arguments else ""
                    if not response_str:
                        raise ValueError(
                            "The language model returned a structured_output tool "
                            "call without arguments."
                        )
                else:
                    # Anthropic and other providers use response_format,
                    # which returns content in message["content"]
                    response_str = response_message["content"]
                    if not response_str and not wire_tool_calls and not audio:
                        if refusal:
                            raise ValueError(
                                "The language model refused to answer: " + refusal
                            )
                        if finish_reason in DETERMINISTIC_FINISH_REASONS:
                            raise DeterministicStopError(
                                _empty_response_message(finish_reason),
                                finish_reason,
                            )
                        raise ValueError(_empty_response_message(finish_reason))
                    response_str = response_str.strip() if response_str else ""
                reasoning_content = response_message.get("reasoning_content")
                thinking_blocks = response_message.get("thinking_blocks")
                if schema:
                    if not response_str and refusal:
                        raise ValueError(
                            "The language model refused to produce the "
                            "structured output: " + refusal
                        )
                    json_instance = orjson.loads(response_str)
                    if (
                        reasoning_content
                        and schema_had_thinking
                        and self.supports_reasoning_trace()
                    ):
                        # The trace replaces whatever the model wrote there, and
                        # comes first, where the schema holds it, as it would
                        # had the model written it.
                        json_instance.pop("thinking", None)
                        json_instance = {"thinking": reasoning_content, **json_instance}
                else:
                    # OpenAI's nested tool-call envelope is the synalinks
                    # `ToolCall` shape (`{id, type, function: {name,
                    # arguments}}`), the `arguments` the JSON-encoded string
                    # the model wrote: parsed when the tool is called.
                    parsed_tool_calls = None
                    if wire_tool_calls:
                        parsed_tool_calls = []
                        for tc in wire_tool_calls:
                            fn = _safe_get(tc, "function")
                            args = _safe_get(fn, "arguments", "")
                            if isinstance(args, (dict, list)):
                                args = orjson.dumps(args).decode()
                            parsed_tool_calls.append(
                                {
                                    "id": _safe_get(tc, "id"),
                                    "type": "function",
                                    "function": {
                                        "name": _safe_get(fn, "name"),
                                        # A call without arguments is sent back as `{}`.
                                        "arguments": args or "{}",
                                    },
                                }
                            )
                    json_instance = {
                        "role": ChatRole.ASSISTANT,
                        "content": response_str,
                    }
                    if reasoning_content:
                        json_instance["reasoning_content"] = reasoning_content
                    if thinking_blocks:
                        json_instance["thinking_blocks"] = thinking_blocks
                    if parsed_tool_calls:
                        json_instance["tool_calls"] = parsed_tool_calls
                    if refusal:
                        json_instance["refusal"] = refusal
                    if audio:
                        json_instance["audio"] = audio
                return JsonDataModel(
                    json=json_instance,
                    schema=schema if schema else ChatMessage.get_schema(),
                    name=f"{self.name}_response",
                )
            except Exception as e:
                warnings.warn(
                    f"Error occured while trying to call {self}: "
                    + str(e)
                    + f"\nReceived response={shorten_text(response_str)}"
                )
                raise

        return await _do_call()

    @classmethod
    def supported_providers(cls):
        """Returns the provider prefixes supported for structured output.

        These are the values accepted before the `/` in `model`, e.g.
        `"openai"` in `"openai/gpt-4o-mini"`. Some are routed to a different
        litellm provider internally (`ollama` to `ollama_chat`, `vllm` to
        `hosted_vllm`, `doubleword` to `openai`, `mirai` and `sglang` to
        `hosted_vllm`); the names listed here are the ones to write in `model`.
        Other litellm providers can still be used for plain chat completion, but
        structured output raises for them.

        ```python
        import synalinks

        print(synalinks.LanguageModel.supported_providers())
        ```

        Returns:
            (list): The sorted list of supported provider prefixes.
        """
        return list(SUPPORTED_PROVIDERS)

    @classmethod
    def provider_info(cls, provider):
        """Returns what a frontend needs to set a provider up.

        ```python
        import synalinks

        print(synalinks.LanguageModel.provider_info("ollama"))
        ```

        Args:
            provider (str): A provider prefix from `supported_providers()`.

        Returns:
            (dict): `provider`, `env` (the environment variables to set, each
                `{"name", "required", "secret"}`), `default_api_base` (or
                `None`) and `lists_models_from` (`"server"` when
                `supported_models()` asks the provider's server, else
                `"catalog"`).

        Raises:
            ValueError: If the provider is not supported.
        """
        model_catalog.check_provider(
            provider, cls.supported_providers(), "language model"
        )
        return model_catalog.provider_info(provider, model_catalog.PROVIDERS[provider])

    @classmethod
    async def supported_models(cls, provider, api_base=None):
        """Returns the language models a provider offers.

        Self-hosted providers and open catalogs (Ollama, vLLM, SGLang, Mirai,
        OpenRouter, Doubleword) are asked for the models they serve, keeping the Ollama
        models that can chat. The others are listed from litellm's model table;
        for OpenAI with `OPENAI_API_KEY` set, narrowed to the models the account
        can use (with its fine-tuned models), from OpenAI's live list.

        ```python
        import synalinks

        print(await synalinks.LanguageModel.supported_models("ollama"))
        ```

        Args:
            provider (str): A provider prefix from `supported_providers()`.
            api_base (str): Optional. The server to ask, for a provider listed
                from its server or from OpenAI's live list (defaults to its base
                URL variable, then to its default endpoint, see
                `provider_info()`).

        Returns:
            (list): The sorted model IDs, ready to pass as `model`
                (e.g. `"openai/gpt-4o-mini"`).

        Raises:
            ValueError: If the provider is not supported, or no endpoint is
                known to list its models from.
            ConnectionError: If the provider's server cannot be reached.
        """
        model_catalog.check_provider(
            provider, cls.supported_providers(), "language model"
        )
        info = model_catalog.PROVIDERS[provider]
        if info.get("server"):
            return await model_catalog.server_models(
                provider, info, api_base, ollama_capability="completion"
            )
        return await model_catalog.account_models(
            provider, model_catalog.catalog_models(provider, ("chat",)), api_base
        )

    @classmethod
    def model_info(cls, model):
        """Returns a model's info from litellm's model table.

        ```python
        import synalinks

        print(synalinks.LanguageModel.model_info("openai/gpt-4o-mini"))
        ```

        Args:
            model (str): The model, e.g. `"openai/gpt-4o-mini"`.

        Returns:
            (dict): `max_input_tokens`, `max_output_tokens`,
                `input_cost_per_token`, `output_cost_per_token` (each `None`
                when unknown) and `capabilities` (e.g. `["vision"]`), or
                `None` when litellm does not know the model (e.g. a
                self-hosted one).
        """
        return model_catalog.model_info(model)
    def supports_reasoning(self):
        """Whether `reasoning_effort` is forwarded to the provider.

        Looked up in litellm's model table: when True, the effort is sent
        with the request (see also `supports_reasoning_trace()`).

        Returns:
            (bool): True if the model takes a reasoning effort.
        """
        return litellm.supports_reasoning(model=self.model)

    def supports_reasoning_trace(self):
        """Whether the native reasoning trace fills the `thinking` field.

        When True and a reasoning effort is sent, the `thinking` field is left
        out of the schema handed to the model and filled with the
        `reasoning_content` of the response instead. When False, the model
        writes `thinking` like any other field.

        Returns:
            (bool): True if the reasoning trace replaces `thinking`.
        """
        return True

    def supports_vision(self):
        """Whether the model accepts images in its input.

        An Ollama model is asked for its capabilities (its server reports
        ``vision`` for the multimodal ones); any other model is looked up in
        litellm's model table, where an unlisted model counts as text-only.
        Register a self-hosted vision model with `litellm.register_model`
        (``supports_vision: True``) to make it pass.

        ```python
        import synalinks

        lm = synalinks.LanguageModel(model="openai/gpt-4o-mini")
        print(lm.supports_vision())  # True
        ```

        Returns:
            (bool): True if the model can read images.
        """
        if self.model.startswith("ollama"):
            return "vision" in self.ollama_capabilities()
        return bool(litellm.supports_vision(model=self.model))

    def supports_audio(self):
        """Whether the model accepts audio in its input.

        Looked up like `supports_vision`: an Ollama model reports ``audio``
        among its capabilities, any other model is looked up in litellm's
        model table (`litellm.register_model` with ``supports_audio_input:
        True`` makes an unlisted one pass).

        ```python
        import synalinks

        lm = synalinks.LanguageModel(model="gemini/gemini-2.5-flash")
        print(lm.supports_audio())  # True
        ```

        Returns:
            (bool): True if the model can listen to audio.
        """
        if self.model.startswith("ollama"):
            return "audio" in self.ollama_capabilities()
        return bool(litellm.supports_audio_input(model=self.model))

    def ollama_capabilities(self):
        """The capabilities an Ollama server reports for this model.

        Returns:
            (list): E.g. ``["completion", "vision", "tools"]``; empty when the
            server cannot be reached or does not know the model.
        """
        try:
            response = httpx.post(
                f"{self.api_base}/api/show",
                json={"model": self.model.split("/", 1)[1]},
                timeout=10,
            )
            response.raise_for_status()
        except httpx.HTTPError:
            return []
        return response.json().get("capabilities") or []

    def supports_softprompt(self):
        """Whether the provider accepts soft prompts (prompt embeddings).

        Only vLLM takes embeddings in place of prompt tokens (its
        ``prompt_embeds`` input, served with ``--enable-prompt-embeds``); the
        hosted APIs take text and media only.

        Returns:
            (bool): True if the model is served by vLLM.
        """
        return self.model.startswith("hosted_vllm")

    def _obj_type(self):
        return "LanguageModel"

    def get_config(self):
        config = {
            "model": self.model,
            "api_base": self.api_base,
            "timeout": self.timeout,
            "retry": self.retry,
            "retry_max_wait": self.retry_max_wait,
            "caching": self.caching,
            "cache_dir": self.cache_dir,
            "name": self.name,
            "description": self.description,
            **self.default_kwargs,
        }
        if self.fallback:
            fallback_config = {
                "fallback": serialization_lib.serialize_synalinks_object(
                    self.fallback,
                )
            }
            return {**fallback_config, **config}
        else:
            return config

    @classmethod
    def from_config(cls, config):
        if "fallback" in config:
            fallback = serialization_lib.deserialize_synalinks_object(
                config.pop("fallback")
            )
            return cls(fallback=fallback, **config)
        else:
            return cls(**config)

    def __repr__(self):
        api_base = f" api_base={self.api_base}" if self.api_base else ""
        return f"<LanguageModel model={self.model}{api_base}>"

call(messages, schema=None, tools=None, tool_schemas=None, streaming=False, **kwargs) async

Call method to generate a response using the language model.

Parameters:

Name Type Description Default
messages dict

A formatted dict of chat messages.

required
schema dict

The target JSON schema for structed output (optional). If None, output a ChatMessage-like answer.

None
tools list | dict

Optional iterable or {name: Tool} mapping of synalinks.modules.Tool the LM may call. Mutually exclusive with schema: schema forces structured output, tools let the LM choose; they cannot both apply to the same call. In the function-calling agent pattern, the tool-call generator uses tools and the final generator uses schema.

None
tool_schemas list

Optional iterable of already-wire-formatted tool declaration dicts (OpenAI {"type": "function", ...} shape) passed through verbatim, appended after any converted tools. Use this to expose tools you already have a schema for without wrapping them in a synalinks.modules.Tool. Subject to the same mutual exclusivity with schema as tools.

None
streaming bool

Enable streaming (optional). Default to False. Can be enabled only if schema is None.

False
**kwargs keyword arguments

The additional keywords arguments forwarded to the LM call.

{}

Returns: (dict): The generated structured response.

Source code in synalinks/src/modules/language_models/language_model.py
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
async def call(
    self,
    messages,
    schema=None,
    tools=None,
    tool_schemas=None,
    streaming=False,
    **kwargs,
):
    """
    Call method to generate a response using the language model.

    Args:
        messages (dict): A formatted dict of chat messages.
        schema (dict): The target JSON schema for structed output (optional).
            If None, output a ChatMessage-like answer.
        tools (list | dict): Optional iterable or `{name: Tool}` mapping of
            `synalinks.modules.Tool` the LM may call. Mutually exclusive
            with `schema`: schema forces structured output, tools let the
            LM choose; they cannot both apply to the same call. In the
            function-calling agent pattern, the tool-call generator uses
            `tools` and the final generator uses `schema`.
        tool_schemas (list): Optional iterable of already-wire-formatted
            tool declaration dicts (OpenAI `{"type": "function", ...}`
            shape) passed through verbatim, appended after any converted
            `tools`. Use this to expose tools you already have a schema for
            without wrapping them in a `synalinks.modules.Tool`. Subject to
            the same mutual exclusivity with `schema` as `tools`.
        streaming (bool): Enable streaming (optional). Default to False.
            Can be enabled only if schema is None.
        **kwargs (keyword arguments): The additional keywords arguments
            forwarded to the LM call.
    Returns:
        (dict): The generated structured response.
    """
    if not messages:
        return None
    if schema and (tools or tool_schemas):
        raise ValueError(
            "`schema` and `tools` cannot be passed to the same LM call: "
            "schema forces structured output, while tools let the LM choose "
            "which to call. Split into two calls: typically the tool-call "
            "generator uses `tools` and the final generator uses `schema`."
        )
    # Cleared per call: a cache hit or a streamed call has no choice to
    # read it from and must not report the previous call's reason.
    self.last_call_finish_reason = None
    _CURRENT_CALL_USAGE.set(None)
    input_kwargs = copy.deepcopy(kwargs)
    # Merge instance-level defaults; per-call kwargs win.
    kwargs = {**self.default_kwargs, **kwargs}
    schema = copy.deepcopy(schema)
    provider = self.model.split("/")[0]

    # Single pass: messages → OpenAI wire shape (nested tool_call
    # envelopes, JSON-string arguments) and synalinks Tools → wire tool
    # declarations. The schema branches below may override `tools` /
    # `tool_choice` for providers that need structured-output-as-tool;
    # cache_control is applied to the system message after this.
    # `messages` here is typically a JsonDataModel from `ops.predict`;
    # wrap it in `ChatMessages` so its `before`-validator converts the
    # dict list into typed `ChatMessage` instances the converter needs.
    _typed_messages = (
        messages
        if hasattr(messages, "messages")
        else ChatMessages(messages=messages.get("messages", []))
    )
    formatted_messages = [_message_to_wire(m) for m in _typed_messages.messages]
    # Inline any deferred multimodal references (a dataset's image_url/
    # input_audio parts carrying a url/path) into base64 payloads, for this
    # batch only. Content already inlined at construction is left untouched.
    formatted_messages = await resolve_content_media(formatted_messages)
    # Media returned by tools (a sandbox plot, a `read_image`, a
    # `read_audio`): images stay inside the tool result for the models
    # that take them there (Claude's `tool_result`, Gemini 3's multimodal
    # function responses; litellm maps both), the rest goes to a
    # follow-up user message, the only role the other APIs take it in.
    media_kinds = {
        part.get("type")
        for message in formatted_messages
        if message["role"] == "tool" and isinstance(message["content"], list)
        for part in message["content"]
    }
    if media_kinds & {"image_url", "input_audio"}:
        formatted_messages = place_tool_result_media(
            formatted_messages,
            native=(
                provider in ("anthropic", "vertex_ai", "bedrock")
                and "claude" in self.model
            )
            or (provider in ("gemini", "vertex_ai") and "gemini-3" in self.model),
            vision="image_url" in media_kinds and self.supports_vision(),
            audio="input_audio" in media_kinds and self.supports_audio(),
        )
    if tools or tool_schemas:
        wire_tools = []
        if tools:
            if isinstance(tools, dict):
                tools = tools.values()
            wire_tools.extend(_tool_to_wire(t) for t in tools)
        if tool_schemas:
            # Already in OpenAI wire shape; pass through verbatim.
            wire_tools.extend(tool_schemas)
        kwargs["tools"] = wire_tools

    # Handle reasoning_effort:
    #   "none"    -> send nothing; leave the model at its provider default.
    #   "disable" -> actively turn native reasoning OFF. This only needs an
    #                explicit flag where reasoning is ON by default, i.e.
    #                ollama's thinking models (qwen3, deepseek-r1, ...), which
    #                reason unless told not to. `think=False` is the ollama
    #                toggle and is safely ignored by non-thinking ollama
    #                models. The ChatGPT backend (`codex/`) also reasons by
    #                default: "disable" is handed to its transport, which
    #                knows the lowest effort the CLI accepts. Opt-in
    #                providers (OpenAI, Anthropic, Gemini, ...) reason only
    #                when enabled, so there is nothing to send.
    #   otherwise -> forward the effort to litellm when the model supports it.
    reasoning_effort = kwargs.pop("reasoning_effort", "none")
    schema_had_thinking = bool(schema) and "thinking" in (
        schema.get("properties") or {}
    )
    if reasoning_effort == "disable":
        if self.model.startswith("ollama"):
            kwargs["think"] = False
        elif self.model.startswith("codex"):
            kwargs["reasoning_effort"] = "disable"
    elif reasoning_effort != "none":
        if self.supports_reasoning():
            kwargs["reasoning_effort"] = reasoning_effort
            if schema_had_thinking and self.supports_reasoning_trace():
                # The LM produces a native reasoning trace via
                # `reasoning_content`; strip `thinking` from the LM
                # schema to save tokens; we re-inject it after the call.
                schema["properties"].pop("thinking", None)
                required = schema.get("required")
                if isinstance(required, list) and "thinking" in required:
                    required.remove("thinking")

    if schema:
        if self.model.startswith("cohere"):
            # Cohere's v2 chat constrains to `schema`; litellm refuses the param.
            extra_body = dict(kwargs.get("extra_body") or {})
            extra_body.setdefault(
                "response_format", {"type": "json_object", "schema": schema}
            )
            kwargs["extra_body"] = extra_body
        elif self.model.startswith("anthropic"):
            # Use response_format for Anthropic - LiteLLM handles this correctly:
            # - For newer models (sonnet-4.5, opus-4.1): uses native output_format
            # - For older models: uses tool call with proper tool_choice handling
            #   (auto when thinking is enabled, forced otherwise)
            _set_if_unset(
                kwargs,
                {
                    "response_format": {
                        "type": "json_schema",
                        "json_schema": {
                            "schema": schema,
                        },
                    },
                },
            )
        elif self.model.startswith("ollama") or self.model.startswith("mistral"):
            # Use constrained structured output for ollama/mistral
            _set_if_unset(
                kwargs,
                {
                    "response_format": {
                        "type": "json_schema",
                        "json_schema": {"schema": schema},
                        "strict": True,
                    },
                },
            )
        elif (
            self.model.startswith("openai")
            or self.model.startswith("azure")
            or self.model.startswith("deepseek")
            or self.model.startswith("together_ai")
            or self.model.startswith("huggingface")
            or self.model.startswith("openrouter")
            or self.model.startswith("groq")
            or self.model.startswith("bedrock")
            or self.model.startswith("codex")
        ):
            # Use constrained structured output for openai/azure
            # plus deepseek, together_ai, huggingface (TGI and
            # Inference Providers), openrouter, groq and bedrock, which
            # honor the same payload (litellm maps it to bedrock's
            # `outputConfig.textFormat`). For a groq or bedrock model
            # without native support, litellm falls back to a forced tool
            # call itself.
            # OpenAI/Azure require the field  "additionalProperties"
            # Also OpenAI/Azure disallow the field "description" in $ref
            if "properties" in schema:
                for prop_key, prop_value in schema["properties"].items():
                    if "$ref" in prop_value and "description" in prop_value:
                        del prop_value["description"]
            _set_if_unset(
                kwargs,
                {
                    "response_format": {
                        "type": "json_schema",
                        "json_schema": {
                            "name": "structured_output",
                            "strict": True,
                            "schema": schema,
                        },
                    }
                },
            )
            if self.model.startswith("openrouter"):
                # Else an endpoint ignoring `response_format` answers in prose.
                provider = dict(kwargs.get("provider") or {})
                provider.setdefault("require_parameters", True)
                chosen = {
                    str(name).lower()
                    for key in ("order", "only")
                    for name in provider.get(key) or ()
                }
                ignored = list(provider.get("ignore") or ())
                known = {str(name).lower() for name in ignored}
                ignored += [
                    name
                    for name in OPENROUTER_IGNORED_PROVIDERS
                    if name not in known and name not in chosen
                ]
                if ignored:
                    provider["ignore"] = ignored
                kwargs["provider"] = provider
        elif self.model.startswith("gemini"):
            _set_if_unset(
                kwargs,
                {
                    "response_format": {
                        "type": "json_schema",
                        "json_schema": {
                            "schema": schema,
                        },
                        "strict": True,
                    }
                },
            )
        elif self.model.startswith("xai"):
            _set_if_unset(
                kwargs,
                {
                    "response_format": {
                        "type": "json_schema",
                        "json_schema": {
                            "schema": schema,
                        },
                        "strict": True,
                    }
                },
            )
        elif self.model.startswith("hosted_vllm"):
            _set_if_unset(
                kwargs,
                {
                    "response_format": {
                        "type": "json_schema",
                        "json_schema": {
                            "name": "structured_output",
                            "schema": schema,
                        },
                        "strict": True,
                    }
                },
            )
        else:
            provider = self.model.split("/")[0]
            raise ValueError(
                f"LM provider '{provider}' not supported yet, please ensure that"
                " they support constrained structured output and fill an issue."
                f" Supported providers: {', '.join(SUPPORTED_PROVIDERS)}."
            )

    if self.api_base:
        kwargs.update(
            {
                "api_base": self.api_base,
            }
        )
    if streaming and schema:
        streaming = False
    if streaming:
        kwargs.update({"stream": True})
    # Enable prompt caching for the system instructions
    # (that only change during training not inference)
    if provider in ("gemini", "anthropic"):
        system_message_with_cache_control = {
            **formatted_messages[0],
            "cache_control": {"type": "ephemeral"},
        }
        formatted_messages[0] = system_message_with_cache_control
    # Persistent on-disk cache: keyed by the fully-formatted request
    # (model, wire messages, schema and final kwargs), so any change to
    # prompts, tools or generation parameters yields a different entry.
    # The `stream` flag is excluded from the key: a streamed call stores
    # the same assistant ChatMessage a non-streamed schema-less call does
    # (streaming forces schema=None), so both share one entry; a hit is
    # replayed as a single-chunk stream when `streaming=True`.
    cache_key = None
    if self._file_cache is not None:
        cache_key = self._file_cache.make_key(
            {
                "model": self.model,
                "messages": formatted_messages,
                "schema": schema,
                "kwargs": {k: v for k, v in kwargs.items() if k != "stream"},
            }
        )
        if cache_key is not None:
            cached_json = self._file_cache.get(cache_key)
            if cached_json is not None:
                self._record_event("cache_hits")
                _CURRENT_CALL_USAGE.set({"cache_hit": True})
                if streaming:
                    return StreamingIterator(_cached_message_to_chunks(cached_json))
                return JsonDataModel(
                    json=cached_json,
                    schema=schema if schema else ChatMessage.get_schema(),
                    name=f"{self.name}_response",
                )
    try:
        response = await self._call_with_retry(
            formatted_messages,
            schema,
            streaming,
            schema_had_thinking,
            **kwargs,
        )
        if cache_key is not None and response is not None:
            if streaming:
                # Cache once the caller drains the stream; an abandoned
                # stream is incomplete and is not cached.
                file_cache = self._file_cache
                response._on_complete = lambda json_instance: file_cache.set(
                    cache_key, json_instance
                )
            else:
                self._file_cache.set(cache_key, response.get_json())
        return response
    except Exception as e:
        warnings.warn(f"All retries failed for {self}: {e}")
        self._record_event("failed_calls")
        if self.fallback:
            self._record_event("fallback_activations")
            response = await self.fallback(
                messages,
                schema=schema,
                streaming=streaming,
                **input_kwargs,
            )
            # The fallback's usage is reported on its own span: this call
            # only reports its failure.
            _CURRENT_CALL_USAGE.set({"error": str(e), "fallback": True})
            return response
        else:
            _CURRENT_CALL_USAGE.set({"error": str(e)})
            return None

model_info(model) classmethod

Returns a model's info from litellm's model table.

import synalinks

print(synalinks.LanguageModel.model_info("openai/gpt-4o-mini"))

Parameters:

Name Type Description Default
model str

The model, e.g. "openai/gpt-4o-mini".

required

Returns:

Type Description
dict

max_input_tokens, max_output_tokens, input_cost_per_token, output_cost_per_token (each None when unknown) and capabilities (e.g. ["vision"]), or None when litellm does not know the model (e.g. a self-hosted one).

Source code in synalinks/src/modules/language_models/language_model.py
@classmethod
def model_info(cls, model):
    """Returns a model's info from litellm's model table.

    ```python
    import synalinks

    print(synalinks.LanguageModel.model_info("openai/gpt-4o-mini"))
    ```

    Args:
        model (str): The model, e.g. `"openai/gpt-4o-mini"`.

    Returns:
        (dict): `max_input_tokens`, `max_output_tokens`,
            `input_cost_per_token`, `output_cost_per_token` (each `None`
            when unknown) and `capabilities` (e.g. `["vision"]`), or
            `None` when litellm does not know the model (e.g. a
            self-hosted one).
    """
    return model_catalog.model_info(model)

ollama_capabilities()

The capabilities an Ollama server reports for this model.

Returns:

Type Description
list

E.g. ["completion", "vision", "tools"]; empty when the

server cannot be reached or does not know the model.

Source code in synalinks/src/modules/language_models/language_model.py
def ollama_capabilities(self):
    """The capabilities an Ollama server reports for this model.

    Returns:
        (list): E.g. ``["completion", "vision", "tools"]``; empty when the
        server cannot be reached or does not know the model.
    """
    try:
        response = httpx.post(
            f"{self.api_base}/api/show",
            json={"model": self.model.split("/", 1)[1]},
            timeout=10,
        )
        response.raise_for_status()
    except httpx.HTTPError:
        return []
    return response.json().get("capabilities") or []

provider_info(provider) classmethod

Returns what a frontend needs to set a provider up.

import synalinks

print(synalinks.LanguageModel.provider_info("ollama"))

Parameters:

Name Type Description Default
provider str

A provider prefix from supported_providers().

required

Returns:

Type Description
dict

provider, env (the environment variables to set, each {"name", "required", "secret"}), default_api_base (or None) and lists_models_from ("server" when supported_models() asks the provider's server, else "catalog").

Raises:

Type Description
ValueError

If the provider is not supported.

Source code in synalinks/src/modules/language_models/language_model.py
@classmethod
def provider_info(cls, provider):
    """Returns what a frontend needs to set a provider up.

    ```python
    import synalinks

    print(synalinks.LanguageModel.provider_info("ollama"))
    ```

    Args:
        provider (str): A provider prefix from `supported_providers()`.

    Returns:
        (dict): `provider`, `env` (the environment variables to set, each
            `{"name", "required", "secret"}`), `default_api_base` (or
            `None`) and `lists_models_from` (`"server"` when
            `supported_models()` asks the provider's server, else
            `"catalog"`).

    Raises:
        ValueError: If the provider is not supported.
    """
    model_catalog.check_provider(
        provider, cls.supported_providers(), "language model"
    )
    return model_catalog.provider_info(provider, model_catalog.PROVIDERS[provider])

supported_models(provider, api_base=None) async classmethod

Returns the language models a provider offers.

Self-hosted providers and open catalogs (Ollama, vLLM, SGLang, Mirai, OpenRouter, Doubleword) are asked for the models they serve, keeping the Ollama models that can chat. The others are listed from litellm's model table; for OpenAI with OPENAI_API_KEY set, narrowed to the models the account can use (with its fine-tuned models), from OpenAI's live list.

import synalinks

print(await synalinks.LanguageModel.supported_models("ollama"))

Parameters:

Name Type Description Default
provider str

A provider prefix from supported_providers().

required
api_base str

Optional. The server to ask, for a provider listed from its server or from OpenAI's live list (defaults to its base URL variable, then to its default endpoint, see provider_info()).

None

Returns:

Type Description
list

The sorted model IDs, ready to pass as model (e.g. "openai/gpt-4o-mini").

Raises:

Type Description
ValueError

If the provider is not supported, or no endpoint is known to list its models from.

ConnectionError

If the provider's server cannot be reached.

Source code in synalinks/src/modules/language_models/language_model.py
@classmethod
async def supported_models(cls, provider, api_base=None):
    """Returns the language models a provider offers.

    Self-hosted providers and open catalogs (Ollama, vLLM, SGLang, Mirai,
    OpenRouter, Doubleword) are asked for the models they serve, keeping the Ollama
    models that can chat. The others are listed from litellm's model table;
    for OpenAI with `OPENAI_API_KEY` set, narrowed to the models the account
    can use (with its fine-tuned models), from OpenAI's live list.

    ```python
    import synalinks

    print(await synalinks.LanguageModel.supported_models("ollama"))
    ```

    Args:
        provider (str): A provider prefix from `supported_providers()`.
        api_base (str): Optional. The server to ask, for a provider listed
            from its server or from OpenAI's live list (defaults to its base
            URL variable, then to its default endpoint, see
            `provider_info()`).

    Returns:
        (list): The sorted model IDs, ready to pass as `model`
            (e.g. `"openai/gpt-4o-mini"`).

    Raises:
        ValueError: If the provider is not supported, or no endpoint is
            known to list its models from.
        ConnectionError: If the provider's server cannot be reached.
    """
    model_catalog.check_provider(
        provider, cls.supported_providers(), "language model"
    )
    info = model_catalog.PROVIDERS[provider]
    if info.get("server"):
        return await model_catalog.server_models(
            provider, info, api_base, ollama_capability="completion"
        )
    return await model_catalog.account_models(
        provider, model_catalog.catalog_models(provider, ("chat",)), api_base
    )

supported_providers() classmethod

Returns the provider prefixes supported for structured output.

These are the values accepted before the / in model, e.g. "openai" in "openai/gpt-4o-mini". Some are routed to a different litellm provider internally (ollama to ollama_chat, vllm to hosted_vllm, doubleword to openai, mirai and sglang to hosted_vllm); the names listed here are the ones to write in model. Other litellm providers can still be used for plain chat completion, but structured output raises for them.

import synalinks

print(synalinks.LanguageModel.supported_providers())

Returns:

Type Description
list

The sorted list of supported provider prefixes.

Source code in synalinks/src/modules/language_models/language_model.py
@classmethod
def supported_providers(cls):
    """Returns the provider prefixes supported for structured output.

    These are the values accepted before the `/` in `model`, e.g.
    `"openai"` in `"openai/gpt-4o-mini"`. Some are routed to a different
    litellm provider internally (`ollama` to `ollama_chat`, `vllm` to
    `hosted_vllm`, `doubleword` to `openai`, `mirai` and `sglang` to
    `hosted_vllm`); the names listed here are the ones to write in `model`.
    Other litellm providers can still be used for plain chat completion, but
    structured output raises for them.

    ```python
    import synalinks

    print(synalinks.LanguageModel.supported_providers())
    ```

    Returns:
        (list): The sorted list of supported provider prefixes.
    """
    return list(SUPPORTED_PROVIDERS)

supports_audio()

Whether the model accepts audio in its input.

Looked up like supports_vision: an Ollama model reports audio among its capabilities, any other model is looked up in litellm's model table (litellm.register_model with supports_audio_input: True makes an unlisted one pass).

import synalinks

lm = synalinks.LanguageModel(model="gemini/gemini-2.5-flash")
print(lm.supports_audio())  # True

Returns:

Type Description
bool

True if the model can listen to audio.

Source code in synalinks/src/modules/language_models/language_model.py
def supports_audio(self):
    """Whether the model accepts audio in its input.

    Looked up like `supports_vision`: an Ollama model reports ``audio``
    among its capabilities, any other model is looked up in litellm's
    model table (`litellm.register_model` with ``supports_audio_input:
    True`` makes an unlisted one pass).

    ```python
    import synalinks

    lm = synalinks.LanguageModel(model="gemini/gemini-2.5-flash")
    print(lm.supports_audio())  # True
    ```

    Returns:
        (bool): True if the model can listen to audio.
    """
    if self.model.startswith("ollama"):
        return "audio" in self.ollama_capabilities()
    return bool(litellm.supports_audio_input(model=self.model))

supports_reasoning()

Whether reasoning_effort is forwarded to the provider.

Looked up in litellm's model table: when True, the effort is sent with the request (see also supports_reasoning_trace()).

Returns:

Type Description
bool

True if the model takes a reasoning effort.

Source code in synalinks/src/modules/language_models/language_model.py
def supports_reasoning(self):
    """Whether `reasoning_effort` is forwarded to the provider.

    Looked up in litellm's model table: when True, the effort is sent
    with the request (see also `supports_reasoning_trace()`).

    Returns:
        (bool): True if the model takes a reasoning effort.
    """
    return litellm.supports_reasoning(model=self.model)

supports_reasoning_trace()

Whether the native reasoning trace fills the thinking field.

When True and a reasoning effort is sent, the thinking field is left out of the schema handed to the model and filled with the reasoning_content of the response instead. When False, the model writes thinking like any other field.

Returns:

Type Description
bool

True if the reasoning trace replaces thinking.

Source code in synalinks/src/modules/language_models/language_model.py
def supports_reasoning_trace(self):
    """Whether the native reasoning trace fills the `thinking` field.

    When True and a reasoning effort is sent, the `thinking` field is left
    out of the schema handed to the model and filled with the
    `reasoning_content` of the response instead. When False, the model
    writes `thinking` like any other field.

    Returns:
        (bool): True if the reasoning trace replaces `thinking`.
    """
    return True

supports_softprompt()

Whether the provider accepts soft prompts (prompt embeddings).

Only vLLM takes embeddings in place of prompt tokens (its prompt_embeds input, served with --enable-prompt-embeds); the hosted APIs take text and media only.

Returns:

Type Description
bool

True if the model is served by vLLM.

Source code in synalinks/src/modules/language_models/language_model.py
def supports_softprompt(self):
    """Whether the provider accepts soft prompts (prompt embeddings).

    Only vLLM takes embeddings in place of prompt tokens (its
    ``prompt_embeds`` input, served with ``--enable-prompt-embeds``); the
    hosted APIs take text and media only.

    Returns:
        (bool): True if the model is served by vLLM.
    """
    return self.model.startswith("hosted_vllm")

supports_vision()

Whether the model accepts images in its input.

An Ollama model is asked for its capabilities (its server reports vision for the multimodal ones); any other model is looked up in litellm's model table, where an unlisted model counts as text-only. Register a self-hosted vision model with litellm.register_model (supports_vision: True) to make it pass.

import synalinks

lm = synalinks.LanguageModel(model="openai/gpt-4o-mini")
print(lm.supports_vision())  # True

Returns:

Type Description
bool

True if the model can read images.

Source code in synalinks/src/modules/language_models/language_model.py
def supports_vision(self):
    """Whether the model accepts images in its input.

    An Ollama model is asked for its capabilities (its server reports
    ``vision`` for the multimodal ones); any other model is looked up in
    litellm's model table, where an unlisted model counts as text-only.
    Register a self-hosted vision model with `litellm.register_model`
    (``supports_vision: True``) to make it pass.

    ```python
    import synalinks

    lm = synalinks.LanguageModel(model="openai/gpt-4o-mini")
    print(lm.supports_vision())  # True
    ```

    Returns:
        (bool): True if the model can read images.
    """
    if self.model.startswith("ollama"):
        return "vision" in self.ollama_capabilities()
    return bool(litellm.supports_vision(model=self.model))

StreamingIterator

Async iterator over LM stream chunks.

Wraps litellm's CustomStreamWrapper (which is async-iterable via __aiter__/__anext__) and yields one normalized dict per non-empty chunk: {"role": "assistant", "reasoning_content": ..., "content": ...}. Chunks containing only role/finish markers are skipped so reasoning-only deltas don't terminate the stream prematurely.

Also accepts a plain sync iterator, useful for tests that mock litellm.acompletion.

When language_model and request_start are provided, the iterator times each consumed stream and, once exhausted, records two latencies onto the LM's operational counters (all-time + the phase captured at request time): time-to-first-token (request start -> first non-empty chunk) and time-to-last-token (request start -> final non-empty chunk). These drive the AvgTimeToFirstToken / AvgTimeToLastToken metrics. A stream that the caller abandons before exhaustion is not recorded (no terminal timing).

When trajectory_start is also provided (an agent set a trajectory_scope), a third latency is recorded: whole-trajectory time-to-first-token, measured from the outermost agent's start (so it includes every tool-calling round) to the first token of the final answer. This drives AvgTrajectoryTimeToFirstToken.

Source code in synalinks/src/modules/language_models/language_model.py
class StreamingIterator:
    """Async iterator over LM stream chunks.

    Wraps litellm's `CustomStreamWrapper` (which is async-iterable via
    `__aiter__`/`__anext__`) and yields one normalized dict per
    non-empty chunk: `{"role": "assistant", "reasoning_content": ..., "content": ...}`.
    Chunks containing only role/finish markers are skipped so reasoning-only
    deltas don't terminate the stream prematurely.

    Also accepts a plain sync iterator, useful for tests that mock
    `litellm.acompletion`.

    When `language_model` and `request_start` are provided, the iterator times
    each consumed stream and, once exhausted, records two latencies onto the
    LM's operational counters (all-time + the phase captured at request time):
    time-to-first-token (request start -> first non-empty chunk) and
    time-to-last-token (request start -> final non-empty chunk). These drive
    the `AvgTimeToFirstToken` / `AvgTimeToLastToken` metrics. A stream that the
    caller abandons before exhaustion is not recorded (no terminal timing).

    When `trajectory_start` is also provided (an agent set a `trajectory_scope`),
    a third latency is recorded: whole-trajectory time-to-first-token, measured
    from the outermost agent's start (so it includes every tool-calling round)
    to the first token of the final answer. This drives
    `AvgTrajectoryTimeToFirstToken`.
    """

    def __init__(
        self,
        iterator,
        *,
        language_model=None,
        op_scope=None,
        request_start=None,
        trajectory_start=None,
    ):
        self._iterator = iterator
        self._is_async = hasattr(iterator, "__anext__") or hasattr(iterator, "__aiter__")
        self._language_model = language_model
        self._op_scope = op_scope
        self._request_start = request_start
        self._trajectory_start = trajectory_start
        self._first_token_time = None
        self._last_token_time = None
        self._recorded = False
        # Accumulates every yielded token so the full assistant message can
        # be handed to `_on_complete` (the on-disk cache writer) once the
        # stream is drained. An abandoned stream never fires the callback.
        self._content_parts = []
        self._reasoning_parts = []
        self._on_complete = None

    def __aiter__(self):
        return self

    def _fire_on_complete(self):
        """Hand the fully-accumulated assistant message to `_on_complete`
        (at most once, and only when the stream actually yielded tokens)."""
        callback = self._on_complete
        if callback is None or not (self._content_parts or self._reasoning_parts):
            return
        self._on_complete = None
        json_instance = {
            "role": ChatRole.ASSISTANT,
            "content": "".join(self._content_parts),
        }
        if self._reasoning_parts:
            json_instance["reasoning_content"] = "".join(self._reasoning_parts)
        callback(json_instance)

    def _record_latencies(self):
        """Accumulate time-to-first / time-to-last token onto the LM counters.

        Idempotent and a no-op when no token was ever yielded or no timing
        context was supplied (e.g. the test-only sync-iterator path).
        """
        if self._recorded:
            return
        self._recorded = True
        lm = self._language_model
        if lm is None or self._request_start is None or self._first_token_time is None:
            return
        increments = {
            "streaming_calls": 1,
            "streaming_ttft_s": self._first_token_time - self._request_start,
            "streaming_ttlt_s": self._last_token_time - self._request_start,
        }
        # Whole-trajectory TTFT, only when this streamed call ran inside an agent
        # trajectory: time from the (outermost) agent's start to the first token.
        if self._trajectory_start is not None:
            increments["trajectory_calls"] = 1
            increments["trajectory_ttft_s"] = (
                self._first_token_time - self._trajectory_start
            )
        _accumulate(lm, "", increments, None)
        if self._op_scope is not None:
            _accumulate(lm, f"{self._op_scope}_", increments, None)

    async def __anext__(self):
        while True:
            try:
                if self._is_async:
                    chunk = await self._iterator.__anext__()
                else:
                    chunk = next(self._iterator)
            except (StopAsyncIteration, StopIteration):
                self._record_latencies()
                self._fire_on_complete()
                raise StopAsyncIteration
            delta = chunk["choices"][0].get("delta") or {}
            content = delta.get("content")
            thinking = delta.get("reasoning_content")
            if content or thinking:
                now = time.perf_counter()
                if self._first_token_time is None:
                    self._first_token_time = now
                self._last_token_time = now
                out = {"role": ChatRole.ASSISTANT}
                if thinking:
                    out["reasoning_content"] = thinking
                    self._reasoning_parts.append(thinking)
                if content:
                    out["content"] = content
                    self._content_parts.append(content)
                return out

    async def aconsume(self, name=None):
        """Drain the stream to exhaustion and return a concrete assistant
        `ChatMessage` `JsonDataModel`.

        The result is the same shape a non-streamed schema-less LM call
        returns (role `assistant`, joined `content`, joined `reasoning_content`
        when present), so a drained stream is indistinguishable downstream from
        an ordinary prediction -- which is what lets the batched predict /
        evaluate loops stream and still score the output. Exhausting the stream
        here also records the time-to-first / time-to-last token latencies.
        """
        content_parts = []
        reasoning_parts = []
        async for chunk in self:
            content = chunk.get("content")
            reasoning = chunk.get("reasoning_content")
            if content:
                content_parts.append(content)
            if reasoning:
                reasoning_parts.append(reasoning)
        json_instance = {
            "role": ChatRole.ASSISTANT,
            "content": "".join(content_parts),
        }
        if reasoning_parts:
            json_instance["reasoning_content"] = "".join(reasoning_parts)
        return JsonDataModel(
            json=json_instance,
            schema=ChatMessage.get_schema(),
            name=name,
        )

aconsume(name=None) async

Drain the stream to exhaustion and return a concrete assistant ChatMessage JsonDataModel.

The result is the same shape a non-streamed schema-less LM call returns (role assistant, joined content, joined reasoning_content when present), so a drained stream is indistinguishable downstream from an ordinary prediction -- which is what lets the batched predict / evaluate loops stream and still score the output. Exhausting the stream here also records the time-to-first / time-to-last token latencies.

Source code in synalinks/src/modules/language_models/language_model.py
async def aconsume(self, name=None):
    """Drain the stream to exhaustion and return a concrete assistant
    `ChatMessage` `JsonDataModel`.

    The result is the same shape a non-streamed schema-less LM call
    returns (role `assistant`, joined `content`, joined `reasoning_content`
    when present), so a drained stream is indistinguishable downstream from
    an ordinary prediction -- which is what lets the batched predict /
    evaluate loops stream and still score the output. Exhausting the stream
    here also records the time-to-first / time-to-last token latencies.
    """
    content_parts = []
    reasoning_parts = []
    async for chunk in self:
        content = chunk.get("content")
        reasoning = chunk.get("reasoning_content")
        if content:
            content_parts.append(content)
        if reasoning:
            reasoning_parts.append(reasoning)
    json_instance = {
        "role": ChatRole.ASSISTANT,
        "content": "".join(content_parts),
    }
    if reasoning_parts:
        json_instance["reasoning_content"] = "".join(reasoning_parts)
    return JsonDataModel(
        json=json_instance,
        schema=ChatMessage.get_schema(),
        name=name,
    )

current_call_usage()

Return the usage of the LanguageModel call that just ran in this task.

None before any call and for streamed calls (usage is only known once the stream is consumed), {"cache_hit": True} when the response came from the file cache, otherwise a dict with input_tokens, output_tokens, total_tokens, cache_read_input_tokens, cache_creation_input_tokens, reasoning_tokens, cost (None when the provider reports none), elapsed_s and finish_reason. When every attempt failed, error (and fallback when a fallback answered).

Source code in synalinks/src/modules/language_models/language_model.py
def current_call_usage():
    """Return the usage of the `LanguageModel` call that just ran in this task.

    `None` before any call and for streamed calls (usage is only known once
    the stream is consumed), `{"cache_hit": True}` when the response came from
    the file cache, otherwise a dict with `input_tokens`, `output_tokens`,
    `total_tokens`, `cache_read_input_tokens`, `cache_creation_input_tokens`,
    `reasoning_tokens`, `cost` (`None` when the provider reports none),
    `elapsed_s` and `finish_reason`. When every attempt failed, `error` (and
    `fallback` when a fallback answered).
    """
    return _CURRENT_CALL_USAGE.get()

OAuthLanguageModel

Bases: LanguageModel

A language model reached through a subscription login instead of an API key.

Flat-rate subscriptions (ChatGPT Plus/Pro/Business) authenticate with OAuth and come without an API key, so they cannot be used through LiteLLM. This class is a drop-in replacement for LanguageModel that sends each request through the vendor's own CLI, which holds the login.

Only the Codex CLI (codex/ prefix) is supported: it is the only one that enforces a JSON schema server-side, which Synalinks relies on for constrained structured output.

Install the CLI with the codex extra (or with npm install -g @openai/codex) and sign in once (interactive, opens a browser):

pip install 'synalinks[codex]'
"$(python -c 'import codex_cli_bin; print(codex_cli_bin.bundled_codex_path())')" login

The CLI of the extra is used when installed, else codex from the PATH; without either, the first call raises an ImportError.

Then use it like any other language model:

import synalinks

language_model = synalinks.OAuthLanguageModel(
    model="codex/gpt-6.1-sol",
    reasoning_effort="low",
)

synalinks.language_models.get("codex/gpt-6.1-sol"), and therefore language_model="codex/gpt-6.1-sol" on any module, returns this class too. Use "codex/default" to let the CLI pick the default model of the account, and codex debug models to list the available ones.

How it works

Only the transport differs from LanguageModel: message formatting, cache, retry, fallback, usage counters and response parsing are inherited. A call without tools spawns codex exec stripped down to a plain completion endpoint (a call with tools spawns codex app-server, see below):

  • The output schema is passed with --output-schema and enforced by the backend in strict mode: every field is required and returned.
  • The system message replaces the built-in coding-agent prompt (a short default one is sent when there is none), and the harness adds nothing to the request: it holds the messages and the schema.
  • The model gets no tool: the shell, web search, apps and plugins are disabled, and the tools the model catalog of the CLI turns on for a model (the "code mode" of the gpt-6 models, sub-agents, the patch tool) are taken out of it. The catalog is read once with codex debug models; if the CLI has none, a warning says so.
  • Whatever the model is given, the sandbox is read-only, without network, and the working directory is an empty temporary directory. A tool call the CLI reports fails the call.
  • The subprocess only inherits an allowlist of environment variables, so the API keys of the parent process are not exposed to it.

Tool calling

tools and tool_schemas are accepted as with LanguageModel, so a FunctionCallingAgent (and the agents built on it) runs unchanged on a codex/ model. A turn that declares tools, or whose history holds tool calls, goes through codex app-server (one process per completion too), where tool calling is native:

  • The tools are declared to the thread (dynamicTools): the model sees them as the functions of the request, as on the API.
  • The earlier turns are appended to the thread's history as the Responses API items they are (thread/inject_items): user and assistant messages, function_call, function_call_output. The last user message starts the turn.
  • The model's first call comes back as an item/tool/call request. The turn is interrupted there and the call returned as a native tool call, which the caller runs: Synalinks keeps the loop, the CLI runs nothing. tool_choice="none" declares no tool; a structured answer over a tool history uses the turn's outputSchema.
  • The same lockdown applies: read-only sandbox without network, empty working directory, no approval, ephemeral thread, tool-free catalog, allowlisted environment. A thread item that is not a message, a reasoning or a call to a declared tool fails the call. The one function the harness declares on its own, request_user_input, is taken out of a turn with tools: the gpt-6 models call it, the CLI refuses it, and each attempt costs an unreported request.

Limitations

  • Without tools, the messages are not sent as a chat. The system and developer messages become the instructions of the CLI, as one block; the rest becomes a single prompt: a lone user message verbatim, a longer conversation as a role-tagged transcript ([USER], [ASSISTANT]). Earlier assistant turns are therefore not sent as assistant messages (a turn with tools sends them as items, see above), and what reaches the backend is shaped by the CLI, which can change between releases (codex debug prompt-input shows it). Results are not directly comparable with the same program on an API-key provider.
  • No streaming: streaming=True raises a ValueError.
  • A turn with tools goes through codex app-server, which has no --ignore-user-config: the user's config.toml is loaded, and what it could add to a request is overridden key by key (its MCP servers are disabled by name, its developer_instructions, notify and model_provider overridden, the effort set to the model's default of the catalog when the call sets none). codex/default takes the model of that configuration there. dynamicTools is experimental in the app-server protocol of codex-cli 0.164 (thread/inject_items is not).
  • tool_choice="required" or a named tool have no native form: they are asked for in the instructions, and an answer without the call is retried. parallel_tool_calls is not controlled: the CLI sends parallel_tool_calls=false, so a turn carries one call.
  • Text only: images and audio parts are dropped, including those a tool returns.
  • No sampling control: the CLI exposes neither temperature nor max_tokens, they are ignored with a warning. Do not rely on this class where a deterministic temperature=0.0 judge is required.
  • A free-form dict field cannot be expressed in strict mode and raises a ValueError.
  • The cost is not tracked (flat-rate subscription); token usage is, and each call counts against the usage limits of the subscription. The backend also caps concurrent requests per account (about five in flight, measured with codex-cli 0.162); beyond that, requests are refused and retried.
  • One process per call. The Codex Python SDK (openai-codex, a persistent codex app-server) sends the same request for the same settings and was not measurably faster.
  • OpenAI recommends API-key authentication for programmatic Codex workflows; check the terms of your subscription before unattended use.

reasoning_effort behaves as with LanguageModel: a value ("low", "medium", "high"...) is sent to the backend, "none" and None keep the default of the model. "disable" and "minimal" send "low", the lowest effort every model accepts (most of them cannot turn reasoning off). A thinking field of the output schema is written by the model.

Parameters:

Name Type Description Default
model str

The model to use, as codex/<model> (e.g. codex/gpt-6.1-sol), or codex/default for the account default.

None
api_base str

Unused, accepted for compatibility with LanguageModel.

required
timeout int

The timeout in seconds of one call, after which the subprocess is killed (Default to 600).

required
retry int

The number of attempts per call (Default to 5).

required
retry_max_wait int

The maximum wait in seconds between two attempts (Default to 60).

required
fallback LanguageModel

The language model to use if every attempt failed (Optional).

required
caching bool

Unused, accepted for compatibility with LanguageModel. Use cache_dir instead.

required
cache_dir str

The directory of the persistent response cache (Optional).

required
name str

The name of the language model (Optional).

required
description str

The description of the language model (Optional).

required
hooks list

The hooks of the language model (Optional).

required
**default_kwargs keyword arguments

The default parameters of every call (e.g. reasoning_effort).

required
Source code in synalinks/src/modules/language_models/oauth_language_model.py
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
@synalinks_export(
    [
        "synalinks.OAuthLanguageModel",
        "synalinks.language_models.OAuthLanguageModel",
    ]
)
@register_synalinks_serializable(package="synalinks")
class OAuthLanguageModel(LanguageModel):
    """A language model reached through a subscription login instead of an API key.

    Flat-rate subscriptions (ChatGPT Plus/Pro/Business) authenticate with
    OAuth and come without an API key, so they cannot be used through
    LiteLLM. This class is a drop-in replacement for `LanguageModel` that
    sends each request through the vendor's own CLI, which holds the login.

    Only the Codex CLI (`codex/` prefix) is supported: it is the only one
    that enforces a JSON schema server-side, which Synalinks relies on for
    constrained structured output.

    Install the CLI with the `codex` extra (or with
    `npm install -g @openai/codex`) and sign in once (interactive, opens a
    browser):

    ```bash
    pip install 'synalinks[codex]'
    "$(python -c 'import codex_cli_bin; print(codex_cli_bin.bundled_codex_path())')" login
    ```

    The CLI of the extra is used when installed, else `codex` from the
    `PATH`; without either, the first call raises an `ImportError`.

    Then use it like any other language model:

    ```python
    import synalinks

    language_model = synalinks.OAuthLanguageModel(
        model="codex/gpt-6.1-sol",
        reasoning_effort="low",
    )
    ```

    `synalinks.language_models.get("codex/gpt-6.1-sol")`, and therefore
    `language_model="codex/gpt-6.1-sol"` on any module, returns this class too.
    Use `"codex/default"` to let the CLI pick the default model of the
    account, and `codex debug models` to list the available ones.

    **How it works**

    Only the transport differs from `LanguageModel`: message formatting,
    cache, retry, `fallback`, usage counters and response parsing are
    inherited. A call without tools spawns `codex exec` stripped down to a
    plain completion endpoint (a call with tools spawns `codex app-server`,
    see below):

    - The output schema is passed with `--output-schema` and enforced by the
        backend in strict mode: every field is required and returned.
    - The system message replaces the built-in coding-agent prompt (a short
        default one is sent when there is none), and the harness adds
        nothing to the request: it holds the messages and the schema.
    - The model gets no tool: the shell, web search, apps and plugins are
        disabled, and the tools the model catalog of the CLI turns on for a
        model (the "code mode" of the gpt-6 models, sub-agents, the patch
        tool) are taken out of it. The catalog is read once with
        `codex debug models`; if the CLI has none, a warning says so.
    - Whatever the model is given, the sandbox is read-only, without
        network, and the working directory is an empty temporary directory.
        A tool call the CLI reports fails the call.
    - The subprocess only inherits an allowlist of environment variables,
        so the API keys of the parent process are not exposed to it.

    **Tool calling**

    `tools` and `tool_schemas` are accepted as with `LanguageModel`, so a
    `FunctionCallingAgent` (and the agents built on it) runs unchanged on a
    `codex/` model. A turn that declares tools, or whose history holds tool
    calls, goes through `codex app-server` (one process per completion too),
    where tool calling is native:

    - The tools are declared to the thread (`dynamicTools`): the model
        sees them as the functions of the request, as on the API.
    - The earlier turns are appended to the thread's history as the
        Responses API items they are (`thread/inject_items`): user and
        assistant messages, `function_call`, `function_call_output`. The
        last user message starts the turn.
    - The model's first call comes back as an `item/tool/call` request.
        The turn is interrupted there and the call returned as a native
        tool call, which the caller runs: Synalinks keeps the loop, the CLI
        runs nothing. `tool_choice="none"` declares no tool; a structured
        answer over a tool history uses the turn's `outputSchema`.
    - The same lockdown applies: read-only sandbox without network, empty
        working directory, no approval, ephemeral thread, tool-free catalog,
        allowlisted environment. A thread item that is not a message, a
        reasoning or a call to a declared tool fails the call. The one
        function the harness declares on its own, `request_user_input`, is
        taken out of a turn with tools: the gpt-6 models call it, the CLI
        refuses it, and each attempt costs an unreported request.

    **Limitations**

    - Without tools, the messages are not sent as a chat. The system and
        developer messages become the instructions of the CLI, as one block;
        the rest becomes a single prompt: a lone user message verbatim, a
        longer conversation as a role-tagged transcript (`[USER]`,
        `[ASSISTANT]`). Earlier assistant turns are therefore not sent as
        assistant messages (a turn with tools sends them as items, see
        above), and what reaches the backend is shaped by the CLI, which can
        change between releases (`codex debug prompt-input` shows it).
        Results are not directly comparable with the same program on an
        API-key provider.
    - No streaming: `streaming=True` raises a `ValueError`.
    - A turn with tools goes through `codex app-server`, which has no
        `--ignore-user-config`: the user's `config.toml` is loaded, and what
        it could add to a request is overridden key by key (its MCP servers
        are disabled by name, its `developer_instructions`, `notify` and
        `model_provider` overridden, the effort set to the model's default
        of the catalog when the call sets none). `codex/default` takes the
        model of that configuration there. `dynamicTools` is experimental
        in the app-server protocol of codex-cli 0.164 (`thread/inject_items`
        is not).
    - `tool_choice="required"` or a named tool have no native form: they
        are asked for in the instructions, and an answer without the call is
        retried. `parallel_tool_calls` is not controlled: the CLI sends
        `parallel_tool_calls=false`, so a turn carries one call.
    - Text only: images and audio parts are dropped, including those a
        tool returns.
    - No sampling control: the CLI exposes neither `temperature` nor
        `max_tokens`, they are ignored with a warning. Do not rely on this
        class where a deterministic `temperature=0.0` judge is required.
    - A free-form `dict` field cannot be expressed in strict mode and
        raises a `ValueError`.
    - The cost is not tracked (flat-rate subscription); token usage is, and
        each call counts against the usage limits of the subscription. The
        backend also caps concurrent requests per account (about five in
        flight, measured with codex-cli 0.162); beyond that, requests are
        refused and retried.
    - One process per call. The Codex Python SDK (`openai-codex`, a
        persistent `codex app-server`) sends the same request for the same
        settings and was not measurably faster.
    - OpenAI recommends API-key authentication for programmatic Codex
        workflows; check the terms of your subscription before unattended
        use.

    `reasoning_effort` behaves as with `LanguageModel`: a value (`"low"`,
    `"medium"`, `"high"`...) is sent to the backend, `"none"` and `None`
    keep the default of the model. `"disable"` and `"minimal"` send `"low"`,
    the lowest effort every model accepts (most of them cannot turn
    reasoning off). A `thinking` field of the output schema is written by
    the model.

    Args:
        model (str): The model to use, as `codex/<model>` (e.g.
            `codex/gpt-6.1-sol`), or `codex/default` for the account default.
        api_base (str): Unused, accepted for compatibility with
            `LanguageModel`.
        timeout (int): The timeout in seconds of one call, after which the
            subprocess is killed (Default to 600).
        retry (int): The number of attempts per call (Default to 5).
        retry_max_wait (int): The maximum wait in seconds between two
            attempts (Default to 60).
        fallback (LanguageModel): The language model to use if every
            attempt failed (Optional).
        caching (bool): Unused, accepted for compatibility with
            `LanguageModel`. Use `cache_dir` instead.
        cache_dir (str): The directory of the persistent response cache
            (Optional).
        name (str): The name of the language model (Optional).
        description (str): The description of the language model (Optional).
        hooks (list): The hooks of the language model (Optional).
        **default_kwargs (keyword arguments): The default parameters of
            every call (e.g. `reasoning_effort`).
    """

    def __init__(self, model=None, **kwargs):
        if model is None:
            raise ValueError("You need to set the `model` argument for any LanguageModel")
        provider, separator, cli_model = model.partition("/")
        if not separator or provider not in OAUTH_PROVIDERS or not cli_model:
            raise ValueError(
                "`OAuthLanguageModel` expects `model` as '<provider>/<model>' with "
                f"a provider among {list(OAUTH_PROVIDERS)} (e.g. 'codex/gpt-6.1-sol'). "
                f"Received: model={model!r}"
            )
        super().__init__(model=model, **kwargs)
        # The ignored parameters already reported, so that a parameter set on
        # every call (e.g. the `temperature` of a `Generator`) warns once.
        self._reported_ignored_params = set()
        # The tool-free model catalog, as JSON, read from the CLI on the
        # first call ("" if the CLI has none).
        self._catalog = None
        # The path of the CLI, looked up on the first call.
        self._codex = None

    async def call(
        self,
        messages,
        schema=None,
        tools=None,
        tool_schemas=None,
        streaming=False,
        **kwargs,
    ):
        """
        Call method to generate a response using the language model.

        Args:
            messages (dict): A formatted dict of chat messages.
            schema (dict): The target JSON schema for structed output (optional).
                If None, output a ChatMessage-like answer.
            tools (list | dict): Optional `synalinks.modules.Tool`s the LM may
                call, as with `LanguageModel` (native, through the app-server:
                see the class docstring). Mutually exclusive with `schema`.
            tool_schemas (list): Optional wire-format tool declarations, as
                with `LanguageModel`.
            streaming (bool): Not supported, must be False.
            **kwargs (keyword arguments): The additional keywords arguments
                forwarded to the LM call (e.g. `tool_choice`,
                `reasoning_effort`).
        Returns:
            (dict): The generated structured response.
        """
        if streaming:
            raise ValueError("`OAuthLanguageModel` does not support streaming.")
        if self._codex is None:
            self._codex = _codex_executable()
        if schema:
            schema = _strict_schema(schema)
        return await super().call(
            messages, schema=schema, tools=tools, tool_schemas=tool_schemas, **kwargs
        )

    async def _acompletion(self, formatted_messages, **kwargs):
        # As for any provider, `None` (what a module passes when it sets no
        # effort) and "none" (not forwarded by the base class) leave the model
        # at its default: no effort is sent.
        reasoning_effort = kwargs.pop("reasoning_effort", None)
        reasoning_effort = _EFFORT_ALIASES.get(reasoning_effort, reasoning_effort)
        response_format = kwargs.pop("response_format", None)
        schema = None
        if response_format:
            schema = (response_format.get("json_schema") or {}).get("schema")
        ignored = sorted(
            k
            for k in kwargs
            if k in _IGNORED_PARAMS and k not in self._reported_ignored_params
        )
        if ignored:
            self._reported_ignored_params.update(ignored)
            warnings.warn(
                f"{self} ignores the parameters {ignored}: the Codex CLI does not "
                "expose them."
            )
        tools = kwargs.pop("tools", None)
        tool_choice = kwargs.pop("tool_choice", None)
        parallel_tool_calls = kwargs.pop("parallel_tool_calls", None)
        cli_model = self.model.split("/", 1)[1]
        if cli_model == "default":
            cli_model = None
        catalog = await self._model_catalog()
        if tools or _has_tool_history(formatted_messages):
            # Declared tools, or earlier calls and results to send as the
            # items they are: the app-server, the only transport with both.
            return await self._app_server_completion(
                formatted_messages,
                cli_model,
                catalog,
                schema=schema,
                tools=tools,
                tool_choice=tool_choice,
                parallel_tool_calls=parallel_tool_calls,
                reasoning_effort=reasoning_effort,
            )
        instructions, prompt = _split_messages(formatted_messages)

        with tempfile.TemporaryDirectory(prefix="synalinks_oauth_") as tmp:
            # The model runs in an empty directory; the request files live
            # next to it, out of its working root.
            workdir = os.path.join(tmp, "workdir")
            os.mkdir(workdir)
            command = _build_codex_command(
                cli_model,
                workdir,
                instructions_path=_write(
                    tmp, "instructions.md", instructions or _DEFAULT_INSTRUCTIONS
                ),
                schema_path=_write(tmp, "schema.json", schema and json.dumps(schema)),
                catalog_path=_write(tmp, "models.json", catalog),
                reasoning_effort=reasoning_effort,
                executable=self._codex,
            )
            stdout, stderr, returncode = await self._run(command, prompt, cwd=workdir)

        content, usage, errors, forbidden = _parse_codex_events(stdout)
        if forbidden:
            raise DeterministicStopError(
                f"The model used tools ({', '.join(forbidden)}) instead of "
                "answering; the response is discarded.",
                "tool_use",
            )
        if returncode != 0 or errors:
            # The same failure is reported as an `error` and a `turn.failed`.
            detail = "; ".join(dict.fromkeys(errors)) or stderr.strip() or stdout.strip()
            message = (
                f"`{command[0]}` failed (exit code {returncode}): "
                f"{detail[-_MAX_ERROR_CHARS:] or '(no output)'}"
            )
            if "invalid_request_error" in detail:
                # A rejected request (unknown model, invalid schema...) fails
                # identically on every attempt.
                raise DeterministicStopError(message, "invalid_request")
            raise RuntimeError(message)
        return _to_model_response(content, usage)

    def _app_server(self, command, cwd):
        """The app-server process of one completion (a seam for the tests)."""
        env = {k: v for k, v in os.environ.items() if k in _ENV_ALLOWLIST}
        return _AppServer(command, env, cwd)

    async def _app_server_completion(
        self,
        formatted_messages,
        cli_model,
        catalog,
        *,
        schema=None,
        tools=None,
        tool_choice=None,
        parallel_tool_calls=None,
        reasoning_effort=None,
    ):
        """One completion through `codex app-server`.

        The conversation with the server: `initialize`, `config/read` (the
        MCP servers of the user's configuration, to disable), `thread/start`
        (instructions, tools, lockdown), `thread/inject_items` (the earlier
        turns), `turn/start` (the last user message, the output schema), then
        the events of the turn until it completes; it is interrupted at the
        model's first tool call. The calls received, if any, are returned as
        native tool calls; else the answer.
        """
        try:
            functions = _offered_tools(tools, tool_choice)
        except ValueError as e:
            raise DeterministicStopError(str(e), "invalid_request") from e
        if functions and schema:
            raise DeterministicStopError(
                "`OAuthLanguageModel` cannot combine a `response_format` with "
                "tools that may be called.",
                "invalid_request",
            )
        instructions, items = _history_items(formatted_messages)
        instructions = "\n\n".join(
            text
            for text in (
                instructions or _DEFAULT_INSTRUCTIONS,
                _tool_choice_instructions(tool_choice, parallel_tool_calls)
                if functions
                else "",
            )
            if text
        )
        # The last user message is the input of the turn; whatever precedes
        # it is the history of the thread.
        turn_input = []
        if items and items[-1]["type"] == "message" and items[-1]["role"] == "user":
            last = items.pop()
            turn_input = [{"type": "text", "text": last["content"][0]["text"]}]
        mode, _ = _tool_choice_mode(tool_choice)
        reasoning_effort = reasoning_effort or _default_effort(catalog, cli_model)

        with tempfile.TemporaryDirectory(prefix="synalinks_oauth_") as tmp:
            workdir = os.path.join(tmp, "workdir")
            os.mkdir(workdir)
            command = _build_app_server_command(
                executable=self._codex,
                catalog_path=_write(tmp, "models.json", catalog),
                tools=bool(functions),
            )
            async with self._app_server(command, workdir) as app:
                try:
                    turn = await asyncio.wait_for(
                        self._run_turn(
                            app,
                            workdir,
                            cli_model,
                            instructions,
                            items,
                            turn_input,
                            dynamic_tools=_dynamic_tools(functions),
                            schema=schema,
                            reasoning_effort=reasoning_effort,
                        ),
                        timeout=self.timeout,
                    )
                except asyncio.TimeoutError as e:
                    raise RuntimeError(
                        f"`{command[0]} app-server` timed out after {self.timeout}s."
                    ) from e

        content, usage, calls, errors, forbidden, status = turn
        if forbidden:
            raise DeterministicStopError(
                f"The model used tools ({', '.join(forbidden)}) instead of "
                "answering; the response is discarded.",
                "tool_use",
            )
        if errors or status == "failed":
            detail = "; ".join(dict.fromkeys(errors)) or f"turn {status}"
            message = f"`{command[0]} app-server` failed: {detail[-_MAX_ERROR_CHARS:]}"
            if "invalid_request_error" in detail:
                raise DeterministicStopError(message, "invalid_request")
            raise RuntimeError(message)
        tool_calls = [
            {
                "id": call["callId"],
                "type": "function",
                "function": {
                    "name": call["tool"],
                    "arguments": json.dumps(call.get("arguments") or {}),
                },
            }
            for call in calls
        ]
        if mode in ("required", "named") and not tool_calls:
            # Retried by the base class, as an empty answer is.
            raise ValueError(
                f"The model called no tool although `tool_choice={tool_choice!r}`."
            )
        return _to_model_response(content, usage, tool_calls=tool_calls)

    async def _run_turn(
        self,
        app,
        workdir,
        cli_model,
        instructions,
        items,
        turn_input,
        *,
        dynamic_tools,
        schema,
        reasoning_effort,
    ):
        """Runs one turn on `app` and returns what it produced.

        Returns:
            (tuple): `(content, usage, calls, errors, forbidden, status)`:
                the answer (None if the model called tools), the usage of its
                last request, the `item/tool/call` params received, the error
                messages, the sorted item types that reveal the model acted,
                and the status of the turn.
        """
        await app.request(
            "initialize",
            {"clientInfo": _APP_SERVER_CLIENT, "capabilities": {"experimentalApi": True}},
        )
        await app.notify("initialized")
        # The app-server loads the user's `config.toml`; its MCP servers would
        # give the model tools. Disabled by name on this thread.
        config = (await app.request("config/read", {}) or {}).get("config") or {}
        mcp_servers = config.get("mcp_servers") or {}
        thread_params = {
            "model": cli_model,
            "baseInstructions": instructions,
            "developerInstructions": "",
            "ephemeral": True,
            "cwd": workdir,
            "sandbox": "read-only",
            "approvalPolicy": "never",
            "config": {"mcp_servers": {name: {"enabled": False} for name in mcp_servers}},
        }
        if dynamic_tools:
            thread_params["dynamicTools"] = dynamic_tools
        thread = await app.request("thread/start", thread_params)
        thread_id = thread["thread"]["id"]
        if items:
            await app.request(
                "thread/inject_items", {"threadId": thread_id, "items": items}
            )
        turn_params = {"threadId": thread_id, "input": turn_input}
        if schema:
            turn_params["outputSchema"] = schema
        if reasoning_effort:
            turn_params["effort"] = reasoning_effort
        turn = await app.request("turn/start", turn_params)
        turn_id = turn["turn"]["id"]

        content = None
        usage = {}
        calls = []
        errors = []
        forbidden = set()
        status = None
        interrupted = False
        while True:
            event = await app.next_event()
            method = event.get("method")
            params = event.get("params") or {}
            if "id" in event:
                # A request of the server. A tool call is what the turn is
                # for: it is kept, never answered, and the turn is interrupted
                # so that the caller runs the tool. Anything else (an approval,
                # a question to the user) means the model acted.
                if method == "item/tool/call":
                    calls.append(params)
                else:
                    forbidden.add(str(method))
                if not interrupted:
                    interrupted = True
                    # Left to end its stream first, or its usage is lost
                    # (see `_STREAM_DRAIN_S`).
                    await asyncio.sleep(_STREAM_DRAIN_S)
                    await app.request(
                        "turn/interrupt", {"threadId": thread_id, "turnId": turn_id}
                    )
            elif method in ("item/started", "item/completed"):
                item = params.get("item") or {}
                item_type = item.get("type")
                if item_type not in _ALLOWED_APP_SERVER_ITEMS:
                    forbidden.add(str(item_type))
                elif item_type == "agentMessage" and method == "item/completed":
                    content = item.get("text")
            elif method == "thread/tokenUsage/updated":
                usage = _turn_usage(params.get("tokenUsage"))
            elif method == "error":
                errors.append(str(params.get("message")))
            elif method == "turn/completed":
                turn = params.get("turn") or {}
                status = turn.get("status")
                error = turn.get("error")
                if error and status != "interrupted":
                    errors.append(str(error.get("message") or error))
                break
        return content, usage, calls, errors, sorted(forbidden), status

    async def _model_catalog(self):
        """Returns the tool-free model catalog of the CLI, as JSON.

        The catalog holds the request settings of each model (reasoning
        levels, verbosity, context window...), so the one of the CLI is kept
        and only its tools are removed. It is read once per instance.

        Returns:
            (str): The catalog, or an empty string if the CLI printed none.
        """
        if self._catalog is None:
            stdout, _, _ = await self._run([self._codex, "debug", "models"], "")
            try:
                self._catalog = json.dumps(_without_tools(json.loads(stdout)))
            except (ValueError, TypeError, KeyError):
                self._catalog = ""
                warnings.warn(
                    f"`codex debug models` printed no model catalog: {self} keeps "
                    "the tools the CLI gives the model by default (the code mode "
                    "of the gpt-6 models), whose calls cost requests and are not "
                    "reported."
                )
        return self._catalog

    async def _run(self, command, prompt, cwd=None):
        """Runs `command` with `prompt` on stdin.

        Returns:
            (tuple): `(stdout, stderr, returncode)`.
        """
        env = {k: v for k, v in os.environ.items() if k in _ENV_ALLOWLIST}
        try:
            process = await asyncio.create_subprocess_exec(
                *command,
                stdin=asyncio.subprocess.PIPE,
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.PIPE,
                env=env,
                cwd=cwd,
            )
        except FileNotFoundError:
            raise DeterministicStopError(
                f"The Codex CLI `{command[0]}` was not found. {_INSTALL_HINT}",
                "cli_not_found",
            )
        try:
            stdout, stderr = await asyncio.wait_for(
                process.communicate(input=prompt.encode("utf-8")),
                timeout=self.timeout,
            )
        except BaseException as e:
            # Timeout or cancellation: never leave the subprocess behind.
            try:
                process.kill()
                await process.wait()
            except ProcessLookupError:
                pass
            if isinstance(e, asyncio.TimeoutError):
                raise RuntimeError(
                    f"`{command[0]}` timed out after {self.timeout}s."
                ) from e
            raise
        return (
            stdout.decode("utf-8", errors="replace"),
            stderr.decode("utf-8", errors="replace"),
            process.returncode,
        )

    @classmethod
    def supported_providers(cls):
        """Returns the provider prefixes this class accepts.

        Returns:
            (list): The sorted list of supported provider prefixes.
        """
        return list(OAUTH_PROVIDERS)

    @classmethod
    def provider_info(cls, provider):
        """Returns what a frontend needs to set a provider up.

        Args:
            provider (str): A provider prefix from `supported_providers()`.

        Returns:
            (dict): `provider`, `env`, `default_api_base` (`None`) and
                `lists_models_from` (`"server"`: the CLI lists them).

        Raises:
            ValueError: If the provider is not supported.
        """
        model_catalog.check_provider(
            provider, cls.supported_providers(), "language model"
        )
        return model_catalog.provider_info(provider, _CODEX_PROVIDER)

    @classmethod
    async def supported_models(cls, provider, api_base=None):
        """Returns the models the Codex CLI offers (those of its model picker).

        Args:
            provider (str): A provider prefix from `supported_providers()`.
            api_base (str): Unused, accepted for compatibility with
                `LanguageModel`.

        Returns:
            (list): The sorted model IDs, ready to pass as `model`
                (e.g. `"codex/gpt-6.1-sol"`).

        Raises:
            ValueError: If the provider is not supported.
            ImportError: If the Codex CLI is not installed.
            ConnectionError: If the CLI prints no model catalog.
        """
        model_catalog.check_provider(
            provider, cls.supported_providers(), "language model"
        )
        language_model = cls(model=f"{provider}/default")
        stdout, stderr, _ = await language_model._run(
            [_codex_executable(), "debug", "models"], ""
        )
        try:
            models = json.loads(stdout)["models"]
        except (ValueError, TypeError, KeyError) as e:
            raise ConnectionError(
                f"`codex debug models` printed no model catalog: {stderr.strip()}"
            ) from e
        return sorted(
            f"{provider}/{model['slug']}"
            for model in models
            if model.get("visibility", "list") == "list"
        )

    def supports_reasoning(self):
        """Whether the model takes a reasoning effort (always True).

        The effort reaches the CLI as `model_reasoning_effort`.
        """
        return True

    def supports_reasoning_trace(self):
        """Whether the reasoning trace fills the `thinking` field (always False).

        The CLI only returns a one-line summary of the reasoning, and not on
        every turn: the model writes the `thinking` field itself.
        """
        return False

    def supports_vision(self):
        """Whether the model accepts images in its input (always False)."""
        return False

    def supports_audio(self):
        """Whether the model accepts audio in its input (always False)."""
        return False

    def _obj_type(self):
        return "OAuthLanguageModel"

    def __repr__(self):
        return f"<OAuthLanguageModel model={self.model}>"

call(messages, schema=None, tools=None, tool_schemas=None, streaming=False, **kwargs) async

Call method to generate a response using the language model.

Parameters:

Name Type Description Default
messages dict

A formatted dict of chat messages.

required
schema dict

The target JSON schema for structed output (optional). If None, output a ChatMessage-like answer.

None
tools list | dict

Optional synalinks.modules.Tools the LM may call, as with LanguageModel (native, through the app-server: see the class docstring). Mutually exclusive with schema.

None
tool_schemas list

Optional wire-format tool declarations, as with LanguageModel.

None
streaming bool

Not supported, must be False.

False
**kwargs keyword arguments

The additional keywords arguments forwarded to the LM call (e.g. tool_choice, reasoning_effort).

{}

Returns: (dict): The generated structured response.

Source code in synalinks/src/modules/language_models/oauth_language_model.py
async def call(
    self,
    messages,
    schema=None,
    tools=None,
    tool_schemas=None,
    streaming=False,
    **kwargs,
):
    """
    Call method to generate a response using the language model.

    Args:
        messages (dict): A formatted dict of chat messages.
        schema (dict): The target JSON schema for structed output (optional).
            If None, output a ChatMessage-like answer.
        tools (list | dict): Optional `synalinks.modules.Tool`s the LM may
            call, as with `LanguageModel` (native, through the app-server:
            see the class docstring). Mutually exclusive with `schema`.
        tool_schemas (list): Optional wire-format tool declarations, as
            with `LanguageModel`.
        streaming (bool): Not supported, must be False.
        **kwargs (keyword arguments): The additional keywords arguments
            forwarded to the LM call (e.g. `tool_choice`,
            `reasoning_effort`).
    Returns:
        (dict): The generated structured response.
    """
    if streaming:
        raise ValueError("`OAuthLanguageModel` does not support streaming.")
    if self._codex is None:
        self._codex = _codex_executable()
    if schema:
        schema = _strict_schema(schema)
    return await super().call(
        messages, schema=schema, tools=tools, tool_schemas=tool_schemas, **kwargs
    )

provider_info(provider) classmethod

Returns what a frontend needs to set a provider up.

Parameters:

Name Type Description Default
provider str

A provider prefix from supported_providers().

required

Returns:

Type Description
dict

provider, env, default_api_base (None) and lists_models_from ("server": the CLI lists them).

Raises:

Type Description
ValueError

If the provider is not supported.

Source code in synalinks/src/modules/language_models/oauth_language_model.py
@classmethod
def provider_info(cls, provider):
    """Returns what a frontend needs to set a provider up.

    Args:
        provider (str): A provider prefix from `supported_providers()`.

    Returns:
        (dict): `provider`, `env`, `default_api_base` (`None`) and
            `lists_models_from` (`"server"`: the CLI lists them).

    Raises:
        ValueError: If the provider is not supported.
    """
    model_catalog.check_provider(
        provider, cls.supported_providers(), "language model"
    )
    return model_catalog.provider_info(provider, _CODEX_PROVIDER)

supported_models(provider, api_base=None) async classmethod

Returns the models the Codex CLI offers (those of its model picker).

Parameters:

Name Type Description Default
provider str

A provider prefix from supported_providers().

required
api_base str

Unused, accepted for compatibility with LanguageModel.

None

Returns:

Type Description
list

The sorted model IDs, ready to pass as model (e.g. "codex/gpt-6.1-sol").

Raises:

Type Description
ValueError

If the provider is not supported.

ImportError

If the Codex CLI is not installed.

ConnectionError

If the CLI prints no model catalog.

Source code in synalinks/src/modules/language_models/oauth_language_model.py
@classmethod
async def supported_models(cls, provider, api_base=None):
    """Returns the models the Codex CLI offers (those of its model picker).

    Args:
        provider (str): A provider prefix from `supported_providers()`.
        api_base (str): Unused, accepted for compatibility with
            `LanguageModel`.

    Returns:
        (list): The sorted model IDs, ready to pass as `model`
            (e.g. `"codex/gpt-6.1-sol"`).

    Raises:
        ValueError: If the provider is not supported.
        ImportError: If the Codex CLI is not installed.
        ConnectionError: If the CLI prints no model catalog.
    """
    model_catalog.check_provider(
        provider, cls.supported_providers(), "language model"
    )
    language_model = cls(model=f"{provider}/default")
    stdout, stderr, _ = await language_model._run(
        [_codex_executable(), "debug", "models"], ""
    )
    try:
        models = json.loads(stdout)["models"]
    except (ValueError, TypeError, KeyError) as e:
        raise ConnectionError(
            f"`codex debug models` printed no model catalog: {stderr.strip()}"
        ) from e
    return sorted(
        f"{provider}/{model['slug']}"
        for model in models
        if model.get("visibility", "list") == "list"
    )

supported_providers() classmethod

Returns the provider prefixes this class accepts.

Returns:

Type Description
list

The sorted list of supported provider prefixes.

Source code in synalinks/src/modules/language_models/oauth_language_model.py
@classmethod
def supported_providers(cls):
    """Returns the provider prefixes this class accepts.

    Returns:
        (list): The sorted list of supported provider prefixes.
    """
    return list(OAUTH_PROVIDERS)

supports_audio()

Whether the model accepts audio in its input (always False).

Source code in synalinks/src/modules/language_models/oauth_language_model.py
def supports_audio(self):
    """Whether the model accepts audio in its input (always False)."""
    return False

supports_reasoning()

Whether the model takes a reasoning effort (always True).

The effort reaches the CLI as model_reasoning_effort.

Source code in synalinks/src/modules/language_models/oauth_language_model.py
def supports_reasoning(self):
    """Whether the model takes a reasoning effort (always True).

    The effort reaches the CLI as `model_reasoning_effort`.
    """
    return True

supports_reasoning_trace()

Whether the reasoning trace fills the thinking field (always False).

The CLI only returns a one-line summary of the reasoning, and not on every turn: the model writes the thinking field itself.

Source code in synalinks/src/modules/language_models/oauth_language_model.py
def supports_reasoning_trace(self):
    """Whether the reasoning trace fills the `thinking` field (always False).

    The CLI only returns a one-line summary of the reasoning, and not on
    every turn: the model writes the `thinking` field itself.
    """
    return False

supports_vision()

Whether the model accepts images in its input (always False).

Source code in synalinks/src/modules/language_models/oauth_language_model.py
def supports_vision(self):
    """Whether the model accepts images in its input (always False)."""
    return False