Text Classification
cortiq
English
cmf
jev
laya
decision-making
structured-output
tool-selection
semantic-routing
on-device
metal
vulkan
intent-classification
resonance-routing
Instructions to use infosave/cmf-decision with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- cortiq
How to use infosave/cmf-decision with cortiq:
# one Rust binary, no additional dependencies cargo install cortiq-cli # or a prebuilt binary from github.com/infosave2007/cmf/releases hf download infosave/cmf-decision --include "*.cmf" --local-dir . ls *.cmf # some repos ship more than one quantization
cortiq run FILE.cmf --prompt "What is the capital of France?"
cortiq serve FILE.cmf --port 8080 # OpenAI-compatible server
- Notebooks
- Google Colab
- Kaggle
File size: 55,938 Bytes
ae561b0 9987fca ae561b0 9987fca ae561b0 9987fca ae561b0 9987fca ae561b0 9987fca ae561b0 9987fca ae561b0 9987fca ae561b0 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 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 | # Cortiq Decision β API
`cortiq serve cortiq-decision.cmf` starts one HTTP server that speaks two
protocols at the same time:
* the **decisions protocol** of Jev / OpenRouter (`POST /api/alpha/decisions`),
with an optional `cmf` extension;
* the **cortiq-router API**, schema `1.1` (`POST /v1/route` and the rest), so
that existing router clients switch by changing the backend address only.
Both run the same local model and the same oracle cascade ([ORACLE.md](ORACLE.md)).
There is no web interface and no CORS layer. Request bodies are never logged:
a log line holds the request id, status, latency and account.
The command and `curl` blocks below were run in order, as written, against
local servers of the published `cortiq-decision.cmf` with cortiq 0.7.8; the
HTTP status each call returned is written after it as `# β 200`. Four parts
used stand-ins or were not run: the `mysql` export of section 8.1 was not run
(the import read a hand-written export of one test key and its counters); the
old router of section 8.3 was a local stand-in, a `cortiq serve` of the same
file holding that imported key at `127.0.0.1:8080`; `cortiq decision
learn` in section 7 called a local mock of the OpenRouter API (`base_url`
pointed at it) instead of OpenRouter; and so did the oracle commands of
[Check your setup](#check-your-setup) in section 1 (`--base-url`, for
`decide` `--oracle-base-url`, pointed at the mock, with a test key). The
examples use `curl` and `jq`.
1. [Start a server](#1-start-a-server)
2. [Keys, plans and limits](#2-keys-plans-and-limits)
3. [Decisions API (Jev / OpenRouter protocol)](#3-decisions-api-jev--openrouter-protocol)
4. [cortiq-router API (schema 1.1)](#4-cortiq-router-api-schema-11)
5. [Administration](#5-administration)
6. [Configuration reference](#6-configuration-reference)
7. [Your own model](#7-your-own-model)
8. [Migrating from cortiq-router](#8-migrating-from-cortiq-router)
## 1. Start a server
Create an API key first. It is printed once; the state directory keeps only
its sha256.
```bash
export KEY=$(cortiq decision keys create --state ./decision.state \
--plan developer --account acme --json | jq -r .key)
```
An optional configuration file (all fields are listed in
[section 6](#6-configuration-reference)):
```bash
cat > decision.json <<'EOF'
{
"default_skill": "banking77",
"task_complexity": {"card_arrival": 0.2, "compromised_card": 0.8},
"routing_tiers": {"low": "my-small-model", "medium": "my-mid-model", "high": "my-large-model"}
}
EOF
```
Start the server. It stays in the foreground until Ctrl-C; the admin API
needs the token in the server's environment and in the shell that calls it.
```bash
export CORTIQ_DECISION_ADMIN_TOKEN=$(openssl rand -hex 24)
cortiq serve cortiq-decision.cmf --decision-config decision.json \
--state ./decision.state --port 8080
```
### CPU, Metal or Vulkan
From Cortiq 0.8.0, the default crates.io CLI includes GPU decision support.
Set the device **before starting the process**:
```bash
# Apple Silicon
CORTIQ_DECISION_DEVICE=metal cortiq serve cortiq-decision.cmf \
--state ./decision.state --port 8080
# A hardware Vulkan device and driver
CORTIQ_DECISION_DEVICE=vulkan cortiq serve cortiq-decision.cmf \
--state ./decision.state --port 8080
```
Use only one server per state directory. CPU remains the default. On multi-GPU
hosts set `CORTIQ_DECISION_VULKAN_ADAPTER` to a unique part of the adapter name.
`GET /healthz` identifies the encoder device and counts completed GPU submissions.
The decisions endpoints and `/v1/route` use the same accelerated path; a device
error is reported, not silently sent to CPU. [Measurements and limits](GPU.md).
A decision server listens on `127.0.0.1:8080` unless `--host` / `--port` say
otherwise. Other `cortiq serve` flags for decision files: `--break-lock`
(only where the state directory's filesystem has no advisory locks: remove a
stale `LOCK` of a dead process), `--shadow-of URL` and
`--shadow-timeout-s N` ([section 8](#8-migrating-from-cortiq-router)), and
`--oracle MODEL` with its companions `--oracle-budget`, `--oracle-max-calls`,
`--oracle-max-price`, `--oracle-key-env`, `--oracle-base-url` and
`--no-oracle-learning`: the oracle in two steps, the OpenRouter key in
`OPENROUTER_API_KEY` and then this flag
([ORACLE.md](ORACLE.md#connect-openrouter)).
Language-model flags (`--task`, `--gpus`, β¦) are refused for a decision file.
```bash
export CORTIQ=http://127.0.0.1:8080
curl -s "$CORTIQ/healthz" # β 200
```
The state directory (mode 0700) holds `LOCK`, `keys.json`, `usage/`,
`oracle.jsonl`, `oracle.state`, `learn.log`, `generations/` and `CURRENT`
(and in shadow mode `shadow.jsonl` with its key `shadow.key`). One server
process per state directory; the CLI may change `keys.json` while it runs
(both take `keys.json.lock` for each change). The server holds an advisory
lock (`flock`) on `LOCK` while it runs, and the file names its pid; the
lock ends with the process however it ends, so a `LOCK` left by a server
that was killed (SIGKILL after a stop timeout, an out-of-memory kill, a
crash of the host) is taken over by the next start, with a warning naming
that pid: a restart policy such as `restart: unless-stopped` needs no
`--break-lock`, also in a container where the server is always pid 1. The
lock of a running process is never broken. Versions up to 0.7.8 do not hold
the lock: do not run one on the same directory at the same time.
<a id="check-your-setup"></a>**Check your setup.** Whether the oracle is
ready can be checked before a server with `--oracle MODEL` is started or
anything is spent:
```bash
cortiq decision oracle check # free: the key, the account, the model
cortiq decision oracle check --test-call # and one tiny structured call, a small fraction of a cent
```
Each check prints β or β with what to do; the exit code is 0 only when the
oracle is ready (`--json`: `ready` and `problems[]`). A server reports its
oracle as `oracle_status` of `GET /healthz` and `status` of `GET
/v1/admin/oracle` ([section 5](#5-administration)), `cortiq decide β¦
--json` as `cmf.oracle.status`:
| Status | What to do |
|---|---|
| `ready` | nothing: what the gate rejects goes to the oracle |
| `no_key` | `export OPENROUTER_API_KEY=β¦` where the server (then restart it) or `decide` runs |
| `bad_key` | fix the variable as the message says (a quote, `Bearer `, a `NAME=β¦` line, whitespace or a non-ASCII byte in it); nothing was sent |
| `disabled` | start the server with `--oracle MODEL`; if the admin switched it off, `POST /v1/admin/oracle {"enabled": true}` |
| `budget_too_small` | `--oracle-budget` of at least the figure the message names (and `--oracle-max-calls` of 1 or more) |
| `budget_exhausted` | a larger `--oracle-budget` or `--oracle-max-calls` (a server counts its whole ledger; each `decide` run has its own budget) |
| `stopped: <reason>` | fix the cause (`http_401` / `http_403` the key was refused, `http_402` no credit, β¦), then `POST /v1/admin/oracle {"enabled": true}`, or one `decide` run with `--oracle-resume` |
An admin limit kept in `oracle.state` is lifted by the `POST
/v1/admin/oracle` the message names, not by a restart. One text or a batch
from the command line (only what the gate rejects is sent; the ledger and
the cache are in `<FILE>.state`, or `--state DIR`):
```bash
cortiq decide cortiq-decision.cmf --skill banking77 -p "the exchange rate you gave me looks wrong" \
--oracle deepseek/deepseek-v4.1-flash
printf '%s\n' '{"text": "I still have not received my new card", "label": "card_arrival"}' \
'{"text": "can I pay my rent with a virtual card"}' > rows.jsonl
cortiq decide cortiq-decision.cmf --skill banking77 --input rows.jsonl --out results.jsonl \
--oracle deepseek/deepseek-v4.1-flash --oracle-budget 0.05
jq -c '{answer, action, source, oracle_cost_usd, flags}' results.jsonl
```
Each batch row adds `answer`, `action` (`local`, `oracle`, `cache` or
`abstain`), `source`, `oracle_cost_usd` and `flags` to the local columns;
`--oracle-resume` turns the oracle on again after a stop rule is fixed, and
a `LOCK` a killed run left is taken over by the next one
([ORACLE.md](ORACLE.md#one-text-or-a-batch)).
## 2. Keys, plans and limits
* A key is `cortiq_` followed by 40 hex characters from the OS random
generator. Send it as `Authorization: Bearer <key>` or `x-api-key: <key>`.
* The server re-reads `keys.json` when it changes (checked at most every 15 s),
so keys created with the CLI while the server runs start working without a
restart. Keys created through the admin API work at once.
* **Open mode** (no key needed) holds only while `keys.json` has no key and
`auth.require` is false; `auth.require: null` (the default) means "required
unless the server listens on loopback". The open caller may reach the
oracle and teach the model when the configuration says
`auth.require: false`. On a loopback address without that setting it may
reach the oracle only when the server was started with `--oracle MODEL`
(the operator enabled it explicitly), and it never teaches the model: a
loopback address says nothing about the client (a reverse proxy on the
same host forwards anyone), so its feedback is not learned and the
oracle's answers to it are cached, not learned. Behind a reverse proxy
set `auth.require: true`.
* A rate window is one fixed minute per account; quotas and credit are
checked before any work is done, and again before every input of a
`/v1/route:batch` after the first (402 at the input that finds them used
up). A *decision* is one answered question: a `/v1/decisions` request
with more questions than the decision quota has left is refused (402,
`requested` in the details) before any work. An oracle call is made only
when its reserved worst-case cost (times `oracle_markup`) fits in the
credit left; otherwise the questions get the `budget` flag, as past the
key's `oracle_budget_usd`. The token quota and the local prices are
checked before the request only, so one request can still go past them
by its own tokens and price; parallel requests of one account are each
checked against the usage recorded before them.
* **Teaching the model** is a permission of its own, `learning_allowed`
(default false for every key, created or imported): the key's feedback is
learned (a label the skill does not have starts a cold start), and the
oracle's answers to its questions become training examples. Without it,
feedback is answered and consumed but not learned, and an oracle answer
teaches the skill only when the question is exactly the skill's own (its
rubric's instructions and criteria over all its labels, as `/v1/route`
asks it). The skills are shared by every account.
| Plan | Requests per minute | Decision quota | Key lifetime |
|---|---|---|---|
| starter | 60 | none | 30 days |
| developer | 120 | 100000 | 30 days |
| pro | 600 | 1000000 | 30 days |
| scale | 3000 | 10000000 | 30 days |
This is cortiq-router's plan table. `auth.plans` in the configuration
replaces entries by name (`{"rate_per_min", "decision_quota", "days"}`,
`days: null` = no expiry); a plan it does not name is refused (400), where
the router minted a key without limits. `cortiq decision keys create` also
takes `--days` (0 = never expires), `--rate-per-min`, `--decision-quota`,
`--token-quota`, `--credit-usd`, `--oracle-budget-usd`, `--oracle-allowed`
and `--learning-allowed` (0 means unlimited for the counters); the admin
API takes the same fields. A key created with the CLI may use the oracle
(`oracle_allowed: true`, like an imported router key) unless it is created
with `--oracle-allowed=false`; the server's oracle switch, budget, the key's
`--oracle-budget-usd` and the stop rules still apply. A key created through
`POST /v1/admin/keys` has `oracle_allowed` only when the body sets it, as
in the router.
```bash
cortiq decision keys list --state ./decision.state
```
Keys through the admin API (router-compatible body; `x-admin-token`):
```bash
curl -s "$CORTIQ/v1/admin/keys" -H "x-admin-token: $CORTIQ_DECISION_ADMIN_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"account": "beta", "plan": "starter", "label": "trial"}' \
| jq '{account, plan, rate_per_min, expires_at}' # β 200
curl -s "$CORTIQ/v1/admin/keys" -H "x-admin-token: $CORTIQ_DECISION_ADMIN_TOKEN" \
| jq '.keys[] | {account, plan, key_hash_prefix}' # β 200
curl -s -X DELETE "$CORTIQ/v1/admin/keys/beta" \
-H "x-admin-token: $CORTIQ_DECISION_ADMIN_TOKEN" # β 200
```
The response of `POST /v1/admin/keys` is the only place the raw key appears.
## 3. Decisions API (Jev / OpenRouter protocol)
`POST /api/alpha/decisions` (also `POST /v1/decisions`), key required.
### 3.1 Request
| Field | Rules |
|---|---|
| `model` | `"cortiq/decision"` or `"cortiq/decision@<12 hex>"` of the served generation; anything else, a Jev name included, is 404 `MODEL_NOT_FOUND` |
| `state` | non-empty string, object or array; an object or array is used as its canonical JSON and the answers are then not certified; at most 32 KiB |
| `questions` | object of 1β32 questions in request order; ids up to 128 characters |
| question | `{type, instructions, criteria}`; `type` is `choice`, `score` or `noul`; `instructions` is required |
| choice `criteria` | object of 2β255 options; option ids 1β256 bytes; each description a string, object, array or `null` up to 24000 bytes; key order is kept |
| score `criteria` | array of 2β10 levels, lowest first |
| noul `criteria` | optional; an object with only `true` and/or `false` |
| `provider`, `user`, `session_id`, `trace` | OpenRouter's optional fields: accepted and ignored |
| `cmf` | extension, all optional: `skill`, `oracle` (bool), `allow_pii_egress` (bool), `round` (`2` or `null`), `explain` (bool), `profile` (`balanced`, `quality-first`, `cost-saver`) |
Any other key at the top level or inside `cmf`, or a key repeated inside any
object, is 400. The body is at most 1 MiB (413) and must be
`Content-Type: application/json` (parameters such as `charset` allowed):
any other content type, or none, is 400 `INVALID_REQUEST` on this API (the
router paths of [section 4](#4-cortiq-router-api-schema-11) answer it with
415, as the router does).
```bash
curl -s "$CORTIQ/api/alpha/decisions" \
-H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{
"model": "cortiq/decision",
"state": "I still have not received my new card",
"questions": {
"intent": {
"type": "choice",
"instructions": "Classify the banking customer message.",
"criteria": {
"card_arrival": "The customer is waiting for a card to arrive",
"card_delivery_estimate": "The customer asks when a card will be delivered",
"lost_or_stolen_card": "The card was lost or stolen"
}
}
}
}' | jq # β 200
```
```json
{
"id": "cmf-dec-1790462653-d5agU2C4o0BM69enIx7G",
"created": 1790462653,
"model": "cortiq/decision@386b6e43fd35",
"provider": "Cortiq",
"answers": {
"intent": {
"type": "choice",
"choice": "card_arrival",
"probabilities": {
"card_arrival": 0.9999062,
"card_delivery_estimate": 0.00003233644,
"lost_or_stolen_card": 0.00006148422
},
"confidence": 0.99985933
}
},
"usage": {"input_tokens": 59, "output_tokens": 3, "cost": 0.0},
"cmf": {
"generation": 0,
"model_sha": "386b6e43fd35d5aa9e9710529dfdf30aa868df98ab4434d8e45dae9829898f85",
"representation_id": "5e83d2345be08aaa526b63addd243bb511f877e1cbab3caab9749a53e9519c25",
"timings_us": {"tokenize": 8, "encode": 2000, "hash": 5, "resonance": 2222, "oracle": 0, "total": 4287},
"questions": {
"intent": {
"action": "local",
"source": "local",
"skill": "banking77",
"match": "subset",
"certified": false,
"gate": {
"accepted": true, "p_top": 0.9999062, "tau": 0.7, "novelty": 0.38166195,
"theta": 0.804234, "is_novel": false, "margin": 0.1587196, "profile": "balanced"
},
"errors": {"card_arrival": 0.097457424, "lost_or_stolen_card": 0.32894346, "card_delivery_estimate": 0.3442838},
"flags": [],
"confident": true,
"complexity": {
"score": 0.15727276, "tier": "low",
"factors": {"base": 0.2, "ambiguity": 0.00009381771, "novelty": 0.38166195, "margin": 0.0, "length": 0.2}
},
"routing": {"target": "my-small-model", "reason": "card_arrival @ complexity 0.16 (low)"},
"decision_path": "router:uncertified_subset"
}
},
"usage": {
"local": {"input_tokens": 59, "output_tokens": 3, "processed_tokens": 10, "cost": 0.0},
"oracle": {"calls": 0, "input_tokens": 0, "output_tokens": 0, "cost": 0.0, "billed": 0.0, "passthrough": true}
}
}
}
```
### 3.2 Skill matching and `certified`
The local model looks only at the option ids (instructions and descriptions
matter to the oracle alone). With L = the option ids of a choice question:
| Match | When | Decided by |
|---|---|---|
| `exact` | L equals the active labels of exactly one skill | that skill's certified gate |
| `subset` | L is a strict subset (at least 2) of one skill's labels | argmin, softmax, margin and novelty over L only; the same T, ΞΈ, Ο; never certified |
| `superset` | one skill's labels plus labels it does not know | the oracle only; its answer teaches that skill only for a key with `learning_allowed` |
| `untrained` | anything else, and every `score` / `noul` question | the oracle only; without it the request is 422 |
`cmf.skill` names the skill and skips the search (an unknown id is 400). An
answer is `certified: true` only for an exact match on a string `state`, the
`balanced` or `quality-first` profile, a skill whose gate is certified, and a
winning label that comes from the training data. The bound (ClopperβPearson
lower bound β₯ 0.95 on the odd calibration half) covers only `certified: true`
answers, all of them `action: local`, and only for traffic like the skill's
calibration split; subset matches, `cost-saver`, object or array states and
uncertified skills also answer `action: local` with `certified: false`.
`quality-first` answers are a sub-selection of the certified acceptance set,
for which no bound of their own was computed. Oracle and cache answers are
never certified.
An exact question is easiest to build from the skill's own rubric:
```bash
curl -s "$CORTIQ/v1/skills/banking77" -H "Authorization: Bearer $KEY" \
| jq '{model: "cortiq/decision", state: "I still have not received my new card",
questions: {intent: {type: "choice", instructions: .rubric.instructions,
criteria: .rubric.criteria}}}' \
| curl -s "$CORTIQ/api/alpha/decisions" -H "Authorization: Bearer $KEY" \
-H 'Content-Type: application/json' --data-binary @- \
| jq '{choice: .answers.intent.choice, confidence: .answers.intent.confidence,
local: (.cmf.questions.intent | {action, match, certified, decision_path})}'
# β 200, 200
```
### 3.3 Response
`200` with `{id, created, model, provider, answers, usage, cmf}` and the header
`x-request-id` equal to `id`.
* **choice, `action: local` or `abstain`** β `{type, choice, probabilities,
confidence}`: `probabilities` has every option in request order,
`confidence = (NΒ·p_max β 1)/(N β 1)` (Jev's formula). Numbers are the
shortest f32 form; `cmf.round: 2` (or `response.round: 2`) rounds to
hundredths and prints 0 and 1 as integers, as Jev does.
* **choice from the oracle or the cache** β `{type, choice}`.
* **score** (oracle only) β `{type, score, legend}` with the level index.
* **noul** (oracle only) β `{type, noul: 1 | 0, value_semantics:
"boolean_verdict_not_probability"}`.
`cmf.questions.<id>` explains each answer:
| Field | Meaning |
|---|---|
| `action` | `local` (gate accepted), `abstain` (gate rejected and no oracle answer), `cache`, `oracle` |
| `source`, `skill`, `match`, `certified` | where the answer came from; section 3.2 |
| `gate` | `accepted`, `p_top` and `tau`, `novelty` and `theta`, `is_novel`, `margin`, `profile` |
| `errors` | reconstruction errors of the 5 best labels (all of them with `cmf.explain`) |
| `flags` | e.g. `oracle_disabled`, `no_key` (with `oracle_disabled`: the key variable is not set), `bad_key` (with `oracle_disabled`: it holds something that is not a key), `consent_off`, `budget`, `stopped`, `oracle_unavailable`, `pii_redacted` |
| `confident` | the answer can be used as is (gate accepted and not novel, or a valid oracle answer) |
| `complexity` | `{score, tier, factors: {base, ambiguity, novelty, margin, length}}`, the cortiq-router formula |
| `routing` | `{target, reason}` when `routing_tiers` maps the tier |
| `decision_path` | `router:certified`, `router:uncertified`, `router:uncertified_subset`, `escalateβcache`, `escalateβoracle`, `escalateβoracle_unavailable`, `escalateβdisabled` |
| `explanation` | with `cmf.explain`: `{top1_vs_top2, decision_path}` |
When a trained question abstains because the oracle is not ready (flags
`oracle_disabled`, `no_key`, `bad_key`, `budget` or `stopped`, not `consent_off`), the
answer also has `cmf.hint`, one line that says what to do, e.g. `"the oracle
key is not set: set OPENROUTER_API_KEY in the server's environment and
restart it"` or `"no oracle answers the questions the local model cannot
decide: start the server with --oracle MODEL and set OPENROUTER_API_KEY"`.
The router API (section 4) has no hint; the server logs it (each hint at most
once a minute; once, at INFO, on a server started without an oracle).
Complexity: `score = Ξ£ weightΒ·factor` with base = `task_complexity[label]`
(default 0.4), ambiguity = 1 β p_top, novelty, margin = 1 β clamp(8Β·margin, 0, 1),
length = clamp(words/40, 0, 1); weights 0.40 / 0.25 / 0.15 / 0.10 / 0.10;
tiers low β€ 0.33 < medium β€ 0.66 < high.
**Profiles** (`cmf.profile`): `balanced` (default) is the certified gate
`p_top β₯ Ο and novelty β€ ΞΈ`; `quality-first` also needs `margin β₯ 0.08` and
`novelty β€ min(ΞΈ, 0.50)` (more abstentions, fewer errors); `cost-saver` uses
`novelty β€ ΞΈ` only and is never certified.
```bash
curl -s "$CORTIQ/api/alpha/decisions" -H "Authorization: Bearer $KEY" \
-H 'Content-Type: application/json' -d '{
"model": "cortiq/decision",
"state": "someone used my card without my permission",
"questions": {"intent": {"type": "choice", "instructions": "Classify the message.",
"criteria": {"compromised_card": null, "card_payment_not_recognised": null, "lost_or_stolen_card": null}}},
"cmf": {"explain": true, "profile": "quality-first", "round": 2}
}' | jq '{answer: .answers.intent, explanation: .cmf.questions.intent.explanation,
complexity: .cmf.questions.intent.complexity.tier, routing: .cmf.questions.intent.routing}'
# β 200
```
### 3.4 Usage and price
* `usage.input_tokens` counts WordPiece tokens of everything the model was
given, as Jev counts them: the `state` plus each question's `instructions`
and `criteria` (keys and values). An exact question over a 77-label rubric
therefore counts thousands of tokens although the encoder reads only the
state; `cmf.usage.local.processed_tokens` is what the encoder read.
* `usage.output_tokens` is the number of values in `probabilities` (1 for an
oracle, cache or noul answer).
* `usage.cost = inputΒ·input_usd_per_1m/1e6 + outputΒ·output_usd_per_1m/1e6 +
request_usd + [oracle passthrough] oracle cost Γ markup`. **All prices are
`"0"` by default**, so local answers cost 0 and oracle answers cost what
OpenRouter charged. Prices are decimal strings in `pricing`.
* Errors are not billed. `GET /v1/usage` shows the caller's own account.
### 3.5 Errors
`{"error": {"code": <HTTP>, "message": "β¦", "metadata": {"reason": "<CODE>",
"retriable": bool, "request_id": "β¦", "details": {β¦}}}}` β OpenRouter's shape
with cortiq-router's codes.
| HTTP | reason | When |
|---|---|---|
| 400 | `INVALID_REQUEST` | schema, limits, duplicate or unknown keys, malformed JSON, a content type other than `application/json` |
| 401 | `UNAUTHORIZED` | key missing, wrong, expired or revoked |
| 402 | `QUOTA_EXCEEDED` | decision or token quota, credit |
| 404 | `MODEL_NOT_FOUND`, `INVALID_REQUEST`, `ADMIN_DISABLED` | unknown model; feedback target not found; admin token not configured |
| 413 | `PAYLOAD_TOO_LARGE` | body over `limits.body_bytes` |
| 422 | `UNSUPPORTED_QUESTION` | untrained question and the oracle not allowed |
| 429 | `RATE_LIMITED`, `OVERLOADED` | the minute window; more than `max_inflight` requests (`Retry-After`) |
| 500 | `INTERNAL` | |
| 502 | `ORACLE_UNAVAILABLE` | untrained question, the oracle call failed |
| 503 | `ORACLE_BUDGET_EXHAUSTED`, `ORACLE_DISABLED` | untrained question, no budget or a stop rule |
A trained question whose oracle call fails is never an error: it is answered
locally with `action: abstain` and the flag `oracle_unavailable`.
```bash
# Jev's model id is not served here
curl -s "$CORTIQ/api/alpha/decisions" -H "Authorization: Bearer $KEY" \
-H 'Content-Type: application/json' \
-d '{"model": "typesafe/jev-1.13", "state": "x",
"questions": {"q": {"type": "choice", "instructions": "i", "criteria": {"a": null, "b": null}}}}' \
| jq .error.metadata.reason # β 404
# score and noul questions need the oracle
curl -s "$CORTIQ/api/alpha/decisions" -H "Authorization: Bearer $KEY" \
-H 'Content-Type: application/json' \
-d '{"model": "cortiq/decision", "state": "The parcel arrived two weeks late.",
"questions": {"urgency": {"type": "score", "instructions": "How urgent?", "criteria": ["low", "medium", "high"]},
"refund": {"type": "noul", "instructions": "Does the customer ask for a refund?"}}}' \
| jq .error.metadata # β 422
# no key
curl -s "$CORTIQ/api/alpha/decisions" -H 'Content-Type: application/json' \
-d '{"model": "cortiq/decision", "state": "x",
"questions": {"q": {"type": "choice", "instructions": "i", "criteria": {"a": null, "b": null}}}}' \
| jq .error.metadata.reason # β 401
```
### 3.6 Feedback, listings, health
`POST /v1/feedback {"id", "question", "label"}` corrects a decision of the
caller's own account (a decision of another account is not found). The label
must be one of the question's options. For a key with `learning_allowed` it
becomes a training example of weight 3, and an option the skill does not
have starts a cold start ([ORACLE.md](ORACLE.md#self-learning)); for any
other key it is consumed and answered 200 with `accepted: false` and
`refused: "learning_not_allowed"`. The router form of feedback (section 4)
accepts any label.
```bash
ID=$(curl -s "$CORTIQ/api/alpha/decisions" -H "Authorization: Bearer $KEY" \
-H 'Content-Type: application/json' \
-d '{"model": "cortiq/decision", "state": "where is the card you sent me",
"questions": {"intent": {"type": "choice", "instructions": "Classify the message.",
"criteria": {"card_arrival": null, "card_delivery_estimate": null}}}}' | jq -r .id) # β 200
curl -s "$CORTIQ/v1/feedback" -H "Authorization: Bearer $KEY" \
-H 'Content-Type: application/json' \
-d "{\"id\": \"$ID\", \"question\": \"intent\", \"label\": \"card_arrival\"}" # β 200
```
| Path | Access | Returns |
|---|---|---|
| `GET /v1/models` | open | the model in the shape of an OpenRouter provider listing (`id`, `pricing` as USD-per-token strings, `context_length` 512, `max_output_length` 255) plus `cmf.skills` with each gate |
| `GET /v1/skills` | key | every skill: `id`, `taxonomy_version`, `labels`, `certified`, `tau`, `theta`, `temperature` and `has_rubric` (whether it has a rubric; the rubric itself is per skill) |
| `GET /v1/skills/{id}` | key | the same plus `tasks`, the whole `gate` and `rubric` (`instructions`, `criteria`; `null` without one) |
| `GET /v1/usage` | key | the caller's account (router format; `x-cmf-extensions: 1` adds token and cost totals) |
| `GET /healthz` | open | status, model, generation, skills, `oracle` (on/off) and `oracle_status` (`ready`, `no_key`, `bad_key`, `disabled`, `budget_exhausted`, `budget_too_small`, `stopped: <reason>`; section 5) |
`/v1/models` only has the listing's shape; Cortiq Decision is not listed on
OpenRouter.
```bash
curl -s "$CORTIQ/v1/models" \
| jq '.data[0] | {id, pricing, skills: [.cmf.skills[] | {id, labels, certified, tau}]}' # β 200
curl -s "$CORTIQ/v1/skills" -H "Authorization: Bearer $KEY" \
| jq '[.skills[] | {id, labels: (.labels | length), certified}]' # β 200
curl -s "$CORTIQ/v1/usage" -H "Authorization: Bearer $KEY" -H 'x-cmf-extensions: 1' \
| jq '{account, totals: .cmf.totals}' # β 200
```
## 4. cortiq-router API (schema 1.1)
The same server answers the router's API with exactly its keys, JSON types and
error envelope, so existing clients need no change:
| Path | Access |
|---|---|
| `POST /v1/route`, `POST /v1/route:batch` (up to 1024 inputs) | key |
| `POST /v1/feedback` `{request_id, correct_task_label}` (`accepted: false` for a key without `learning_allowed`) | key |
| `GET /v1/taxonomies`, `GET /v1/taxonomies/{id}` | key |
| `GET /v1/usage`, `GET /v1/escalations?limit=N` | key (own account only) |
| `GET /v1/healthz`, `GET /v1/readyz`, `GET /metrics` (Prometheus) | open |
| `POST /v1/admin/keys`, `GET /v1/admin/keys`, `DELETE /v1/admin/keys/{account}` | `x-admin-token` |
* `taxonomy_id` is a skill id (`banking77`, `clinc150`, `massive`, or your
own); without it the server uses `default_skill`, else the file's only skill.
* `options`: `policy_profile` (`balanced`; also `cost-saver`, `quality-first`),
`allow_oracle` (`true`: consent to the oracle for this input if it is
undetermined), `allow_pii_egress` (`false`), `top_k` (3, 1β64),
`return_explanation` (`false`), `routing_table_id` (adds `routing` when
`routing_tiers` is configured).
* `decision.confidence` is the calibrated `p_top` (1 for an oracle or cache
answer), `raw_confidence` the winner's `1/(1+E)`; `scores[]` carry
`probability`, `score` and `reconstruction_error`. A rejected answer that is
not escalated has the flag `low_confidence` and `confident: false`.
* `input.embedding` (bring your own vector) must have the signal's dimension
(4480) and `embedding_model`; it is decided locally only.
* The header `x-cmf-extensions: 1` adds a `cmf` object (the `cmf-dec-β¦` id,
action, `certified`, gate) to results, listings and errors, and
`cmf.oracle_status` to `/v1/healthz`. Without it the responses have the
router's keys only.
* The router's flags keep their vocabulary: an escalation the oracle does
not answer is `low_confidence` plus `oracle_disabled` (also when the key
variable is not set or not a key β `no_key` and `bad_key` appear only on
the decisions API),
`consent_off`, `budget`, `stopped` or `oracle_unavailable`, in the answer
and in `/v1/escalations`; the hint of the decisions API is only logged.
```bash
curl -s "$CORTIQ/v1/route" -H "Authorization: Bearer $KEY" \
-H 'Content-Type: application/json' \
-d '{"input": {"text": "I still have not received my new card"},
"taxonomy_id": "banking77", "options": {"return_explanation": true}}' | jq # β 200
```
```json
{
"schema_version": "1.1",
"request_id": "req_18d9004c29a85ce8000005",
"decision": {
"task_id": 12,
"task_label": "card_arrival",
"taxonomy_id": "banking77",
"confidence": 0.9980738,
"confident": true,
"raw_confidence": 0.91119707,
"margin": 0.116146445,
"is_novel": false,
"novelty_score": 0.40156537,
"complexity": {
"score": 0.1677992,
"tier": "low",
"factors": {"base": 0.2, "ambiguity": 0.0019261837, "novelty": 0.40156537, "margin": 0.07082844, "length": 0.2}
},
"source": "router",
"flags": []
},
"scores": [
{"task_id": 12, "task_label": "card_arrival", "probability": 0.9980738, "score": 0.91119707, "reconstruction_error": 0.097457424},
{"task_id": 14, "task_label": "card_linking", "probability": 0.001209337, "score": 0.7950506, "reconstruction_error": 0.25778145},
{"task_id": 1, "task_label": "activate_my_card", "probability": 0.00019758825, "score": 0.76862156, "reconstruction_error": 0.30103046}
],
"explanation": {"top1_vs_top2": "card_arrival leads card_linking by 0.116 score", "decision_path": "router:certified"},
"usage": {"billable_decisions": 1, "oracle_calls": 0},
"meta": {
"model_version": "cortiq/decision@386b6e43fd35",
"taxonomy_version": "banking77@1",
"latency_ms": 1.234917,
"embedding_latency_ms": 1.986042,
"served_by": "cortiq/0.7.8"
}
}
```
With the configuration of section 1 (`default_skill`, `task_complexity`,
`routing_tiers`), a route without `taxonomy_id` goes to `banking77` and names
the client's model for the complexity tier:
```bash
curl -s "$CORTIQ/v1/route" -H "Authorization: Bearer $KEY" \
-H 'Content-Type: application/json' \
-d '{"input": {"text": "someone used my card without my permission"},
"options": {"routing_table_id": "default"}}' \
| jq '{label: .decision.task_label, tier: .decision.complexity.tier, routing}' # β 200
curl -s "$CORTIQ/v1/route:batch" -H "Authorization: Bearer $KEY" \
-H 'Content-Type: application/json' \
-d '{"taxonomy_id": "clinc150",
"inputs": [{"text": "what will the weather be tomorrow"}, {"text": "set an alarm for 7 am"}]}' \
| jq '[.results[].decision | {task_label, confident}]' # β 200
```
Feedback, listings and probes:
```bash
RID=$(curl -s "$CORTIQ/v1/route" -H "Authorization: Bearer $KEY" \
-H 'Content-Type: application/json' \
-d '{"input": {"text": "can I get a card for my teenager"}, "taxonomy_id": "banking77"}' \
| jq -r .request_id) # β 200
curl -s "$CORTIQ/v1/feedback" -H "Authorization: Bearer $KEY" \
-H 'Content-Type: application/json' \
-d "{\"request_id\": \"$RID\", \"correct_task_label\": \"age_limit\"}" # β 200
curl -s "$CORTIQ/v1/taxonomies" -H "Authorization: Bearer $KEY" \
| jq '[.taxonomies[] | {taxonomy_id, taxonomy_version, labels: (.labels | length)}]' # β 200
curl -s "$CORTIQ/v1/usage" -H "Authorization: Bearer $KEY" | jq .usage # β 200
curl -s "$CORTIQ/v1/escalations?limit=5" -H "Authorization: Bearer $KEY" | jq .summary # β 200
curl -s "$CORTIQ/v1/healthz" # β 200
curl -s "$CORTIQ/v1/readyz" # β 200
curl -s "$CORTIQ/metrics" | grep '^cortiq_decisions_total' # β 200
curl -s "$CORTIQ/v1/route" -H "Authorization: Bearer $KEY" \
-H 'Content-Type: application/json' \
-d '{"input": {"text": "hello"}, "taxonomy_id": "data-assistant"}' | jq .error.code # β 404
```
Router errors keep the router's envelope: `{"schema_version": "1.1",
"request_id": "req_β¦", "error": {"code": "<CODE>", "message": "β¦",
"retriable": bool, "details": null}}` with `INVALID_REQUEST` /
`EMBEDDING_REQUIRED` (400), `UNAUTHORIZED` (401), `QUOTA_EXCEEDED` (402),
`TAXONOMY_NOT_FOUND` (404, `details: {"taxonomy_id"}`), `RATE_LIMITED` (429);
`retriable` is true only for 429 and 500. A body the router's own framework
rejects is answered as the router answers it, in plain text: a non-JSON
content type 415, a body over `limits.body_bytes` 413, malformed JSON 400,
wrong field types 422.
## 5. Administration
Every admin path needs `x-admin-token` equal to the environment variable named
by `auth.admin_token_env` (`CORTIQ_DECISION_ADMIN_TOKEN`); when the variable is
unset the admin API answers 404 `ADMIN_DISABLED`.
| Path | Does |
|---|---|
| `POST / GET /v1/admin/keys`, `DELETE /v1/admin/keys/{account}`, `DELETE /v1/admin/keys/hash/{hash12}` | create (raw key returned once), list, revoke |
| `GET /v1/admin/usage` | usage of every account |
| `GET /v1/admin/oracle`, `POST /v1/admin/oracle {"enabled", "budget_usd", "max_calls"}` | oracle status: `status` (`ready`; `no_key` β the key variable is unset or empty; `bad_key` β it holds something that is not a key, never sent; `disabled` β not configured or switched off by the admin; `budget_exhausted` β something was spent and the budget (what is left cannot hold the smallest possible call, or a call it refused) or `max_calls` is used up; `budget_too_small` β nothing spent, and the budget cannot hold one call, `min_call_usd`, or `max_calls` is 0; `stopped: <reason>` β a stop rule), `configured` = `oracle.enabled` of the configuration, `enabled` = not switched off by a stop rule or the admin, `key_present`, `key_ok`, `key_problem` (by position and length, never a byte of the key), `key_trimmed` (surrounding whitespace was trimmed), `key_env` (the variable's name, never its value), `model`, `max_price`, `min_call_usd` (the least budget a call needs: the smallest possible call's reservation, or, once the budget refused a longer call β a budget below the smallest call included β that call's, the larger), spent, calls, stop reason, `last_error` (the code of the last failed call; both from a closed code set, another value read back from `oracle.state` is `unknown_code`); switch it and lower limits within the configuration (kept in `oracle.state` across restarts: when one of them refuses the next call, `cmf.hint` and the startup line name it and the `POST /v1/admin/oracle` that lifts it) |
| `GET /v1/admin/learning` | buffer, cache, quarantine, attempts, promotions, recent events |
| `GET /v1/admin/generations`, `POST /v1/admin/rollback {"generation": N}` | generations; serve generation N (0 = the base file) |
| `GET /v1/admin/shadow` | agreement statistics in shadow mode, and the routed requests not compared since the start (section 8) |
```bash
A="x-admin-token: $CORTIQ_DECISION_ADMIN_TOKEN"
curl -s "$CORTIQ/v1/admin/usage" -H "$A" | jq 'keys' # β 200
curl -s "$CORTIQ/v1/admin/oracle" -H "$A" | jq '{status, enabled, configured, key_present, budget_usd, spent_usd}' # β 200
curl -s "$CORTIQ/v1/admin/learning" -H "$A" | jq '{generation, buffer: .buffer.examples, promotions}' # β 200
curl -s "$CORTIQ/v1/admin/generations" -H "$A" | jq '{current, model}' # β 200
curl -s "$CORTIQ/v1/admin/rollback" -H "$A" -H 'Content-Type: application/json' \
-d '{"generation": 0}' | jq # β 200
```
With the server stopped, the same is available from the CLI:
`cortiq decision rollback --state DIR --to N --model FILE`, and
`cortiq decision materialize FILE --state DIR -o OUT.cmf` writes the served
generation as one self-contained file (this also works while a server runs).
## 6. Configuration reference
`--decision-config FILE` takes a JSON object; every field is optional and an
unknown key is an error. The defaults:
```json
{
"state_dir": null,
"default_skill": null,
"auth": {"require": null, "admin_token_env": "CORTIQ_DECISION_ADMIN_TOKEN", "key_prefix": "cortiq_"},
"limits": {"body_bytes": 1048576, "state_bytes": 32768, "questions": 32, "max_inflight": 64},
"pricing": {"input_usd_per_1m": "0", "output_usd_per_1m": "0", "request_usd": "0",
"oracle_passthrough": true, "oracle_markup": 1.0},
"response": {"round": null},
"oracle": {"enabled": false, "default_per_request": true,
"base_url": "https://openrouter.ai/api/v1", "api_key_env": "OPENROUTER_API_KEY",
"model": "deepseek/deepseek-v4.1-flash",
"provider": {"sort": "price", "require_parameters": true, "allow_fallbacks": true,
"max_price": {"prompt": 0.1, "completion": 0.5}},
"max_tokens_per_question": 64, "deadline_s": 30, "budget_usd": 1.0, "max_calls": 10000,
"max_errors": 30, "redact_pii": true, "title": "cortiq-decision", "data_collection": null},
"cache": {"enabled": true, "threshold": 0.97, "cap": 50000},
"learning": {"enabled": true, "refit_min_new": 25, "dedup": 0.995, "cold_start": true, "synchronous": false},
"feedback": {"pending_cap": 50000},
"complexity_weights": {"base": 0.4, "ambiguity": 0.25, "novelty": 0.15, "margin": 0.1, "length": 0.1},
"complexity_tiers": [{"tier": "low", "max": 0.33}, {"tier": "medium", "max": 0.66}, {"tier": "high", "max": 1.0}],
"task_complexity": {},
"routing_tiers": {}
}
```
`state_dir: null` means `<FILE>.state` next to the model. Secrets are never
part of the file: the admin token and the OpenRouter key are read from the
environment variables it names. `auth.plans` may override the plan table.
`oracle.base_url` must be https, or plain http to a loopback address only (a
local proxy). The oracle, cache and learning sections are explained in
[ORACLE.md](ORACLE.md). `cortiq serve --oracle MODEL` and its companions
override the `oracle` section (and `--no-oracle-learning` sets
`learning.enabled` false); a file that sets `oracle.provider` keeps its
`max_price` unless `--oracle-max-price` is given.
## 7. Your own model
A skill is trained from JSONL rows `{"text", "label"}`: a non-empty text up to
32 KiB, a label of 1β256 bytes, no other keys. Duplicates and conflicting
labels are kept (conflicts are reported). A label with a single training row
is kept inactive.
```bash
# the router's datasets_dir layout, read as its read_label_prompts does: one
# directory per label, every file in it, one text per line (trimmed, blank
# lines skipped); in a .jsonl file the line's "text" (else "prompt") string,
# else the line itself; a file that is not UTF-8 is skipped
python3 - datasets > train.jsonl <<'PY'
import json, os, sys
root = sys.argv[1]
for label in sorted(os.listdir(root)):
d = os.path.join(root, label)
if not os.path.isdir(d):
continue
for name in sorted(os.listdir(d)):
p = os.path.join(d, name)
if not os.path.isfile(p):
continue
try:
lines = open(p, encoding="utf-8").read().split("\n")
except (OSError, UnicodeDecodeError):
continue
for line in lines:
text = line.strip()
if not text:
continue
if name.endswith(".jsonl"):
try:
v = json.loads(text)
except ValueError:
v = None
if isinstance(v, dict):
t = v["text"] if "text" in v else v.get("prompt")
if isinstance(t, str):
text = t
print(json.dumps({"text": text, "label": label}, ensure_ascii=False))
PY
cat > question.json <<'EOF'
{
"instructions": "Classify the task type of the user request.",
"criteria": {
"chitchat": "Small talk and greetings",
"code": "Writing, fixing or explaining code",
"creative-writing": "Stories, poems and other creative text",
"extraction": "Pulling structured facts out of a text",
"math": "Solving a math problem",
"qa": "A factual question",
"summarization": "Shortening a text",
"translation": "Translating between languages"
}
}
EOF
```
`train` takes the encoder of any decision file byte for byte and adds one
skill; `add-skill` copies every existing skill byte for byte and adds a new one
(an existing id is refused). Outputs are never overwritten.
```bash
cortiq decision train --encoder cortiq-decision.cmf --skill data-assistant \
--train train.jsonl --question question.json -o data-assistant.cmf
cortiq decision add-skill cortiq-decision.cmf --skill data-assistant \
--train train.jsonl --question question.json -o cortiq-decision-plus.cmf
cortiq decision info data-assistant.cmf
cortiq decision verify data-assistant.cmf
cortiq decide data-assistant.cmf -p "Write a Python function that merges two sorted lists"
```
* **Skill id**: `[a-z0-9][a-z0-9_-]{0,63}`; it is the `taxonomy_id` of the
router API.
* **Several training files**: repeat `--train` (the published file was
trained on train and dev).
* **Calibration**: `--calibration FILE`, or carved out of the training rows
(inside each label, rows in sha256 order, every fifth row). The calibration
rows are split by sha256 order into halves: the even half sets the
temperature T and the novelty threshold ΞΈ (95th percentile), the odd half
picks Ο from a fixed grid of 14 thresholds. The gate is **certified** when a
threshold keeps at least 100 accepted odd-half rows with a ClopperβPearson
lower bound β₯ 0.95 at Ξ± = 0.05/14; the one accepting the most rows wins.
Otherwise the skill is served with ΞΈ only and `certified: false` (the small
example above has 160 rows and is not certified). 20 % of the calibration
rows are held out for self-learning (champion/challenger).
* **K** (`--k`, default 16) is the most directions per label (a label with n
rows gets min(K, n β 1)). The published skills use K chosen by 5-fold
cross-validation on train βͺ dev: 32 (banking77), 16 (clinc150),
24 (massive).
* `--dev FILE` records dev accuracy in the skill; `--threads N` changes speed,
never the result; `--json` prints the build report.
* After writing, the file is re-opened and the calibration errors, confidence
and novelty are recomputed and compared bit for bit.
**Pre-training through the oracle** on unlabelled traffic: only the texts the
gate rejects are asked; answers already in driver ledgers are reused by
request sha256 (`--answers`). Each label with new examples is refitted,
checked on the holdout, and the gate is re-certified; if the certified gate
would be lost every promotion of the skill is undone. The effect measured on
the three public sets was neutral ([ORACLE.md](ORACLE.md#measured-effect)).
```bash
cat > traffic.jsonl <<'EOF'
{"text": "Could you tidy up this paragraph so it reads better"}
{"text": "What is the capital of Australia"}
{"text": "my flight got cancelled what now"}
{"text": "the invoice total looks wrong"}
{"text": "Book a table for two at 8 pm"}
EOF
cat > oracle.json <<'EOF'
{
"oracle": {
"enabled": true,
"base_url": "https://openrouter.ai/api/v1",
"api_key_env": "OPENROUTER_API_KEY",
"model": "deepseek/deepseek-v4.1-flash",
"budget_usd": 0.05,
"max_calls": 100
}
}
EOF
export OPENROUTER_API_KEY="<your OpenRouter key>"
cortiq decision learn data-assistant.cmf --traffic traffic.jsonl \
--oracle-config oracle.json -o data-assistant-learned.cmf
```
The key is read from the variable `api_key_env` names
([ORACLE.md](ORACLE.md#connect-openrouter)); every live call is reserved in
`<OUTPUT>.oracle.jsonl` before it is sent.
Batch evaluation and timing (`--input` rows `{"text", "label"?}`; one JSON
result per row, never the text; totals on stderr):
```bash
cortiq decide data-assistant.cmf --input train.jsonl --bench --out rows.jsonl
```
## 8. Migrating from cortiq-router
### 8.1 Keys
Existing keys keep working without reissue: the router stores `sha256(raw)`
and so does this server. Export the router's `api_keys` table (and, for
continuous quotas, `usage_counters`) as JSON lines:
```text
mysql -N -B -r -e "SELECT JSON_OBJECT('key_hash',key_hash,'account',account,'plan',plan,
'label',label,'active',active,'rate_per_min',rate_per_min,'decision_quota',decision_quota,
'expires_at',expires_at,'created_at',created_at) FROM api_keys" cortiq > api_keys.jsonl
mysql -N -B -r -e "SELECT JSON_OBJECT('account',account,'decisions',decisions,
'oracle_calls',oracle_calls) FROM usage_counters" cortiq > usage_counters.jsonl
```
```bash
cortiq decision keys import --state ./router.state --from api_keys.jsonl \
--usage usage_counters.jsonl
```
* Also accepted: MySQL Shell, Workbench and phpMyAdmin JSON exports, and the
`[[api_keys]]` of the router's TOML configuration (`--format router-toml`;
raw keys are hashed as they are read).
* The import is idempotent, checks every row before writing anything, never
overwrites or re-activates a stored key, and prints no key or hash.
* Imported keys may use the oracle (`oracle_allowed: true`, as every key could
in the router); `--oracle-allowed=false` imports them without it.
* Imported keys do not teach the model (`learning_allowed: false`): their
`/v1/feedback` is answered and consumed but not learned (`accepted:
false`), where the router learned from every key; `--learning-allowed`
imports them with it. The oracle's answers to their `/v1/route` still
teach the skill (the route question is the skill's own).
* `--usage` writes the usage ledger: run it while no server holds the state
directory. Importing a newer export again adds only the new keys, the
revocations and the growth of the counters.
### 8.2 The taxonomy
A router taxonomy becomes a skill with the same id, trained from the router's
datasets (section 7 shows the conversion of a `datasets_dir`). The router's
self-learned examples are vectors of its own encoder (the router keeps no
texts), so they cannot be carried over; the new skill learns again from the
oracle and from feedback on live traffic.
### 8.3 Shadow mode, switch and rollback
Run the new server next to the old router with `--shadow-of URL`. Every request
to a router path is forwarded unchanged to the old router, with the client's
own `Authorization` header, and its answer goes back to the client byte for
byte (errors included), so clients see no change. A `/v1/route` or
`/v1/route:batch` that the old router answered 200 is also decided locally
(no oracle, no learning, no billing) and compared line by line in
`<state>/shadow.jsonl`: labels, confidence, latency and a keyed digest of the
text (HMAC-SHA256 under a random key kept in `<state>/shadow.key`), never the
text. An answer that is not a 200 β a missing or wrong key, a quota, an error
β is neither decided nor written. The comparisons have 4 slots of their own,
apart from the decisions API's `limits.max_inflight` (64 more may wait; one
past that is dropped), and at most 256 requests are forwarded at a time;
`GET /v1/admin/shadow` counts the requests not compared since the start
(`skipped`).
**Configuration.** The router's own settings do not come over by themselves;
write them into a decision configuration from the router's TOML. For the
router's `deploy/config.prod.toml`:
```bash
cat > router.json <<'EOF'
{
"default_skill": "data-assistant",
"auth": {"admin_token_env": "CORTIQ_ADMIN_TOKEN"},
"complexity_tiers": [{"tier": "low", "max": 0.33}, {"tier": "medium", "max": 0.48},
{"tier": "high", "max": 1.0}]
}
EOF
export CORTIQ_ADMIN_TOKEN="<the router's admin token>"
```
* `default_skill` is the router's `taxonomy_id`. Router clients may leave
`taxonomy_id` out; with several skills in the file and no `default_skill`
such a request is 400. A skill id is `[a-z0-9][a-z0-9_-]{0,63}`: a router
`taxonomy_id` outside that pattern (the default `general.task-type`) cannot
be kept, and its clients have to send the new id.
* `auth.admin_token_env` names the router's variable, so that the portal's
`x-admin-token` keeps working when this server answers `/v1/admin/keys`
after the switch (the same token also reads `/v1/admin/shadow`).
* Copy `[complexity_weights]`, `[[complexity_tiers]]` (the default here is
medium β€ 0.66), `[task_complexity]` and `[routing_tiers]` (without them no
response has a `routing` block) as JSON, and `[auth.plans]` if the router
changes them (`duration_days` is `days` here, 0 is `null`).
* A router that escalated to an oracle: add the `oracle` section of
[ORACLE.md](ORACLE.md#connect-openrouter).
`--shadow-of` reaches the old router over **https**, or over plain http **only
at a loopback address** (`127.0.0.1`, `::1`, `localhost`), because the
clients' keys pass through it. cortiq-router itself speaks plain http, with
TLS at nginx, so run the new server on the router's host and point it at the
router's own port there.
```bash
# on the router's host; the old router listens on port 8080
cortiq serve cortiq-decision-plus.cmf --decision-config router.json \
--state ./router.state --port 8090 --shadow-of http://127.0.0.1:8080
```
A router on another machine is named by an https address in front of it
(`--shadow-of https://router-a.internal`); `--shadow-of http://router-a:8080`
is refused. `$ROUTER_KEY` below is the key of one of your router clients: it
was imported in 8.1, so both the old router and the new server accept it.
```bash
export SHADOW=http://127.0.0.1:8090
curl -s "$SHADOW/v1/route" -H "Authorization: Bearer $ROUTER_KEY" \
-H 'Content-Type: application/json' \
-d '{"input": {"text": "Write a haiku about autumn"}, "taxonomy_id": "data-assistant"}' \
| jq .decision.task_label # β 200
curl -s "$SHADOW/v1/admin/shadow" -H "x-admin-token: $CORTIQ_ADMIN_TOKEN" \
| jq '{lines, compared, agree, agreement, confident, latency_ms, skipped}' # β 200
```
nginx on the same host, in place of the `upstream cortiq` block of the
router's `deploy/nginx/nginx.conf`:
```nginx
upstream cortiq {
least_conn;
server 127.0.0.1:8090; # the new server: in shadow mode, then after the switch
server 127.0.0.1:8080 backup; # the old router: only while the new server restarts
keepalive 64;
}
```
With nginx on another machine, as in the router's own `deploy/nginx`
(`server router-a:8080`), start the new server with `--host 0.0.0.0` as well
(it listens on `127.0.0.1` by default) and write `router-a:8090` and
`router-a:8080 backup` in the upstream; `--shadow-of` stays
`http://127.0.0.1:8080`. One state directory serves one process, so quotas
and usage are counted per server.
**1. Shadow.** Start the shadow server, point the upstream at it as above and
reload nginx. Clients are still answered by the old router. Watch
`GET /v1/admin/shadow` (overall agreement, agreement when both sides are
confident, per-label agreement, latency).
**2. Switch** only when the agreement and your own spot checks are good
enough. In shadow mode `/v1/admin/keys` went to the old router: keys the
portal minted or revoked since 8.1, and the decisions the router counted,
are only there. Hold the portal's admin calls, stop the shadow server
(Ctrl-C), export `api_keys` and `usage_counters` again as in 8.1 and import
them once more (new keys are added, keys the router marks inactive are
revoked, and only the growth of the counters is added), then start the
server on the same state directory and port without `--shadow-of`. nginx
sends requests to the old router (`backup`) while the new server is down;
nginx itself needs no change.
```bash
cortiq decision keys import --state ./router.state --from api_keys.jsonl \
--usage usage_counters.jsonl
cortiq serve cortiq-decision-plus.cmf --decision-config router.json \
--state ./router.state --port 8090
```
```bash
curl -s "$SHADOW/v1/route" -H "Authorization: Bearer $ROUTER_KEY" \
-H 'Content-Type: application/json' \
-d '{"input": {"text": "Translate good morning into French"}, "taxonomy_id": "data-assistant"}' \
| jq '{label: .decision.task_label, source: .decision.source}' # β 200
curl -s "$SHADOW/v1/usage" -H "Authorization: Bearer $ROUTER_KEY" | jq .usage # β 200
```
**3. Rollback.** Leave `server 127.0.0.1:8080;` as the only line of the
upstream and reload nginx; the old router was never changed. Decisions the
new server counted after the switch stay in its own usage ledger, not in the
router's `usage_counters`.
|