Skip to content

feat(decisions): add OpenAI as a Decisions provider behind a shared decisions format - #45214

Merged
mateo-berri merged 21 commits into
mainfrom
litellm_openai_decisions_provider
Oct 8, 2026
Merged

mateo-berri merged 21 commits into
mainfrom
litellm_openai_decisions_provider

Conversation

@mateo-berri

@mateo-berri mateo-berri commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • OpenAI's Decisions API (gpt-6-luna) was not a Decisions provider
  • openai/gpt-6-luna deployments got a 400 on both Decisions routes
  • /v1/decisions rejected input_image parts with a 400 for every provider

How it solves it:

  • Both routes now convert to one shared decisions format inside the proxy
  • Each provider translates that shared format to and from its own wire format
  • New openai provider calls https://api.openai.com/v1/decisions, passing OpenAI-format input and questions through field for field
  • /v1/decisions to OpenAI keeps images, multi-message input, safety_identifier, names and refusals
  • Jev-compatible providers get System One bodies exactly as before, and images there return a clear 400
  • Cached and cache-write input tokens are billed at their own rates
  • The gpt-6-luna price row now lists /v1/decisions
  • The openai provider reads litellm.api_key, litellm.openai_key, litellm.api_base, OPENAI_BASE_URL and OPENAI_API_BASE in the same order as other OpenAI calls
  • System One choice or score questions without instructions get a short default one for OpenAI, and single-option questions get a clear 400 before any upstream call
  • Built on main's BaseDecisionsConfig and shared HTTP handler dispatch from refactor(decisions): dispatch /v1/decisions through provider configs and the shared HTTP handler #45130: OpenAIDecisionsConfig is registered in ProviderConfigManager, and every config translates the shared format through transform_decisions_request and parse_response

Intentional product change: /v1/systemone responses only carry answers for the questions the caller asked; extra answer keys a provider returns are dropped, while extra fields inside each answer are kept

Stacked on #45184, so until that merges the diff also shows its commits. The new work is the commits after 77872f6

User Flow

Before: a proxy admin who adds openai/gpt-6-luna as a decider gets a 400 on every Decisions call

  1. They add model: openai/gpt-6-luna as luna next to their existing typesafe/jev-latest as jev
  2. A developer sends POST http://localhost:4000/v1/decisions with "model": "luna", input messages and a questions list
  3. They get 400 Unknown Decisions provider 'openai', the same on POST http://localhost:4000/v1/systemone
  4. Adding an input_image part fails validation with a 400, even for jev

After: both models answer on both routes, and images reach OpenAI

  1. They add model: openai/gpt-6-luna as luna next to their existing typesafe/jev-latest as jev
  2. A developer sends the same POST http://localhost:4000/v1/decisions body
  3. They get 200 with an answers list carrying their question names, and /v1/systemone returns an answers map keyed by name
  4. An input_image part gets a real answer from luna, while jev returns 400 saying it accepts text input only
  5. Every 200 carries a non-zero x-litellm-response-cost header

Pre-Submission checklist

Please complete all items before asking a LiteLLM maintainer to review your PR

  • I have added meaningful tests
  • The handful of test files covering my change pass locally, e.g. uv run pytest tests/unit/<your_test_file>.py -v. Leave the suites (make test-unit-*, make test-unit) to CI: it finishes in ~15 minutes where a laptop takes an hour or more
  • My PR passes all required CI/CD checks (e.g., lint, schema.d.ts sync check, etc.)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have received a Greptile Confidence Score of at least 4/5 before requesting a maintainer review (Greptile reviews automatically once the PR is opened; only comment @greptileai to re-request a review after pushing changes)

Delays in PR merge?

If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review)

Screenshots / Proof of Fix

Proxy started with python -m litellm.proxy.proxy_cli --config both_config.yaml --port 4000, calling OpenAI and Typesafe for real

model_list:
  - model_name: luna
    litellm_params:
      model: openai/gpt-6-luna
      api_key: os.environ/OPENAI_API_KEY
  - model_name: jev
    litellm_params:
      model: typesafe/jev-latest
      api_key: os.environ/TYPESAFE_API_KEY

general_settings:
  master_key: os.environ/DECISIONS_PROOF_MASTER_KEY

luna_systemone_body.json (jev_systemone_body.json is the same with "model": "jev"):

{
  "model": "luna",
  "state": "Customer wrote: I was charged twice for my order #4411 and want one of the charges refunded today.",
  "questions": {
    "is_refund_request": {
      "type": "noul",
      "instructions": "Is the customer asking for a refund?"
    },
    "urgency": {
      "type": "choice",
      "instructions": "How urgent is this?",
      "criteria": {
        "low": "can wait a week",
        "high": "needs action today"
      }
    },
    "frustration": {
      "type": "score",
      "instructions": "How frustrated is the customer?",
      "criteria": [
        "calm",
        "mildly annoyed",
        "angry"
      ]
    }
  }
}

luna_openai_body.json (jev_openai_body.json is the same with "model": "jev"):

{
  "model": "luna",
  "input": [
    {
      "role": "user",
      "type": "message",
      "content": [
        {
          "type": "input_text",
          "text": "Customer wrote: I was charged twice for my order #4411 and want one of the charges refunded today."
        }
      ]
    }
  ],
  "questions": [
    {
      "type": "predicate",
      "name": "is_refund_request",
      "instructions": "Is the customer asking for a refund?"
    },
    {
      "type": "choice",
      "name": "needs_human",
      "instructions": "Should a human agent take over?",
      "choices": [
        {
          "value": true,
          "description": "billing dispute that needs a person"
        },
        {
          "value": false
        }
      ]
    },
    {
      "type": "score",
      "name": "frustration",
      "instructions": "How frustrated is the customer?",
      "levels": [
        {
          "label": "calm"
        },
        {
          "label": "mildly annoyed"
        },
        {
          "label": "angry",
          "description": "uses hostile language"
        }
      ]
    }
  ]
}

luna_image_body.json (jev_image_body.json is the same with "model": "jev"):

{
  "model": "luna",
  "input": [
    {
      "type": "message",
      "role": "user",
      "content": [
        {
          "type": "input_text",
          "text": "Here is the swatch the customer uploaded."
        },
        {
          "type": "input_image",
          "image_url": "data:image/png;base64,iVBORw0KGgoAAAANSU...(64x64 solid red PNG)",
          "detail": "low"
        }
      ]
    }
  ],
  "questions": [
    {
      "type": "predicate",
      "name": "is_red",
      "instructions": "Is the swatch mostly red?"
    },
    {
      "type": "choice",
      "name": "color",
      "instructions": "Which color dominates the swatch?",
      "choices": [
        {
          "value": "red"
        },
        {
          "value": "green"
        },
        {
          "value": "blue"
        }
      ]
    }
  ],
  "safety_identifier": "proof-user-1"
}

Before (77872f6)

luna, System One format at /v1/systemone

  1. curl -sS -i localhost:4000/v1/systemone -H "Authorization: Bearer $LITELLM_MASTER_KEY" -H 'Content-Type: application/json' -d @luna_systemone_body.json
  2. Output:
HTTP/1.1 400 Bad Request
x-litellm-response-cost: 0
{"error":{"message":"litellm.BadRequestError: Unknown Decisions provider 'openai'. Supported providers: perplexity, typesafe, openrouter, cloudflare, strands_decider\n\nLiteLLM: model group 'luna' failed with the error above. No fallback was attempted.","type":"invalid_request_error","param":null,"code":"400"}}

luna, OpenAI format at /v1/decisions

  1. curl -sS -i localhost:4000/v1/decisions -H "Authorization: Bearer $LITELLM_MASTER_KEY" -H 'Content-Type: application/json' -d @luna_openai_body.json
  2. Output:
HTTP/1.1 400 Bad Request
x-litellm-response-cost: 0
{"error":{"message":"litellm.BadRequestError: Unknown Decisions provider 'openai'. Supported providers: perplexity, typesafe, openrouter, cloudflare, strands_decider\n\nLiteLLM: model group 'luna' failed with the error above. No fallback was attempted.","type":"invalid_request_error","param":null,"code":"400"}}

luna, OpenAI format with an image at /v1/decisions

  1. curl -sS -i localhost:4000/v1/decisions -H "Authorization: Bearer $LITELLM_MASTER_KEY" -H 'Content-Type: application/json' -d @luna_image_body.json
  2. Output:
HTTP/1.1 400 Bad Request
x-litellm-response-cost: 0
{"error":{"message":"litellm.BadRequestError: Invalid Decisions request: 6 validation errors for OpenAIDecisionRequestBody\ninput.str\n  Input should be a valid string [type=string_type, input_value=[{'type': 'message', 'rol...==', 'detail': 'low'}]}], input_t ... (remaining pydantic validation errors trimmed)

jev, System One format at /v1/systemone

  1. curl -sS -i localhost:4000/v1/systemone -H "Authorization: Bearer $LITELLM_MASTER_KEY" -H 'Content-Type: application/json' -d @jev_systemone_body.json
  2. Output:
HTTP/1.1 200 OK
x-litellm-response-cost: 1.6422e-05
{"model":"jev-1.13.0","answers":{"is_refund_request":{"type":"noul","noul":0.99},"urgency":{"type":"choice","choice":"high","confidence":1.0,"probabilities":{"low":0.0,"high":1.0}},"frustration":{"type":"score","score":0.95,"confidence":0.86,"legend":{"0":"calm","1":"mildly annoyed","2":"angry"},"probabilities":{"0":0.07,"1":0.91,"2":0.02}}},"usage":{"input_tokens":391,"output_tokens":67}}

jev, OpenAI format at /v1/decisions

  1. curl -sS -i localhost:4000/v1/decisions -H "Authorization: Bearer $LITELLM_MASTER_KEY" -H 'Content-Type: application/json' -d @jev_openai_body.json
  2. Output:
HTTP/1.1 200 OK
x-litellm-response-cost: 1.6422e-05
{"model":"jev-1.13.0","answers":[{"type":"predicate","name":"is_refund_request","probability":0.99},{"type":"choice","name":"needs_human","choice":true,"probabilities":[{"value":true,"probability":0.65},{"value":false,"probability":0.35}],"confidence":0.3},{"type":"score","name":"frustration","score":0.84,"probabilities":[{"value":0,"label":"calm","probability":0.16},{"value":1,"label":"mildly annoyed","probability":0.84},{"value":2,"label":"angry","probability":0.0}],"confidence":0.76}],"usage":{"input_tokens":391,"input_tokens_details":{"cached_tokens":0,"cache_write_tokens":0},"output_tokens":61,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":452}}

jev, OpenAI format with an image at /v1/decisions

  1. curl -sS -i localhost:4000/v1/decisions -H "Authorization: Bearer $LITELLM_MASTER_KEY" -H 'Content-Type: application/json' -d @jev_image_body.json
  2. Output:
HTTP/1.1 400 Bad Request
x-litellm-response-cost: 0
{"error":{"message":"litellm.BadRequestError: Invalid Decisions request: 6 validation errors for OpenAIDecisionRequestBody\ninput.str\n  Input should be a valid string [type=string_type, input_value=[{'type': 'message', 'rol...==', 'detail': 'low'}]}], input_t ... (remaining pydantic validation errors trimmed)

After (39012f1)

luna, System One format at /v1/systemone

  1. curl -sS -i localhost:4000/v1/systemone -H "Authorization: Bearer $LITELLM_MASTER_KEY" -H 'Content-Type: application/json' -d @luna_systemone_body.json
  2. Output:
HTTP/1.1 200 OK
x-litellm-response-cost: 3.9699999999999996e-05
{"model":"gpt-6-luna","answers":{"is_refund_request":{"type":"noul","noul":1.0},"urgency":{"type":"choice","choice":"high","confidence":1.0,"probabilities":{"low":0.0,"high":1.0}},"frustration":{"type":"score","score":0.96,"confidence":0.76,"legend":{"0":"calm","1":"mildly annoyed","2":"angry"},"probabilities":{"0":0.1,"1":0.84,"2":0.06}}},"usage":{"input_tokens":397,"output_tokens":0}}

luna, OpenAI format at /v1/decisions

  1. curl -sS -i localhost:4000/v1/decisions -H "Authorization: Bearer $LITELLM_MASTER_KEY" -H 'Content-Type: application/json' -d @luna_openai_body.json
  2. Output:
HTTP/1.1 200 OK
x-litellm-response-cost: 4.01e-05
{"model":"gpt-6-luna","answers":[{"type":"predicate","name":"is_refund_request","probability":1.0},{"type":"choice","name":"needs_human","choice":true,"probabilities":[{"value":true,"probability":0.99},{"value":false,"probability":0.01}],"confidence":0.98},{"type":"score","name":"frustration","score":0.74,"probabilities":[{"value":0,"label":"calm","probability":0.26},{"value":1,"label":"mildly annoyed","probability":0.74},{"value":2,"label":"angry","probability":0.0}],"confidence":0.61}],"usage":{"input_tokens":401,"input_tokens_details":{"cached_tokens":0,"cache_write_tokens":0},"output_tokens":0,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":401}}

luna, OpenAI format with an image at /v1/decisions

  1. curl -sS -i localhost:4000/v1/decisions -H "Authorization: Bearer $LITELLM_MASTER_KEY" -H 'Content-Type: application/json' -d @luna_image_body.json
  2. Output:
HTTP/1.1 200 OK
x-litellm-response-cost: 2.77e-05
{"model":"gpt-6-luna","answers":[{"type":"predicate","name":"is_red","probability":1.0},{"type":"choice","name":"color","choice":"red","probabilities":[{"value":"red","probability":1.0},{"value":"green","probability":0.0},{"value":"blue","probability":0.0}],"confidence":1.0}],"usage":{"input_tokens":277,"input_tokens_details":{"cached_tokens":0,"cache_write_tokens":0},"output_tokens":0,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":277}}

jev, System One format at /v1/systemone

  1. curl -sS -i localhost:4000/v1/systemone -H "Authorization: Bearer $LITELLM_MASTER_KEY" -H 'Content-Type: application/json' -d @jev_systemone_body.json
  2. Output:
HTTP/1.1 200 OK
x-litellm-response-cost: 1.6422e-05
{"model":"jev-1.13.0","answers":{"is_refund_request":{"type":"noul","noul":0.99},"urgency":{"type":"choice","choice":"high","confidence":1.0,"probabilities":{"low":0.0,"high":1.0}},"frustration":{"type":"score","score":0.95,"confidence":0.85,"legend":{"0":"calm","1":"mildly annoyed","2":"angry"},"probabilities":{"0":0.08,"1":0.9,"2":0.02}}},"usage":{"input_tokens":391,"output_tokens":67}}

jev, OpenAI format at /v1/decisions

  1. curl -sS -i localhost:4000/v1/decisions -H "Authorization: Bearer $LITELLM_MASTER_KEY" -H 'Content-Type: application/json' -d @jev_openai_body.json
  2. Output:
HTTP/1.1 200 OK
x-litellm-response-cost: 1.6422e-05
{"model":"jev-1.13.0","answers":[{"type":"predicate","name":"is_refund_request","probability":0.99},{"type":"choice","name":"needs_human","choice":true,"probabilities":[{"value":true,"probability":0.64},{"value":false,"probability":0.36}],"confidence":0.28},{"type":"score","name":"frustration","score":0.84,"probabilities":[{"value":0,"label":"calm","probability":0.16},{"value":1,"label":"mildly annoyed","probability":0.84},{"value":2,"label":"angry","probability":0.0}],"confidence":0.76}],"usage":{"input_tokens":391,"input_tokens_details":{"cached_tokens":0,"cache_write_tokens":0},"output_tokens":68,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":459}}

jev, OpenAI format with an image at /v1/decisions

  1. curl -sS -i localhost:4000/v1/decisions -H "Authorization: Bearer $LITELLM_MASTER_KEY" -H 'Content-Type: application/json' -d @jev_image_body.json
  2. Output:
HTTP/1.1 400 Bad Request
x-litellm-response-cost: 0
{"error":{"message":"litellm.BadRequestError: Decisions provider 'typesafe' cannot serve this request: input_image content parts are not supported because System One providers accept text input only\n\nLiteLLM: model group 'jev' failed with the error above. No fallback was attempted.","type":"invalid_request_error","param":null,"code":"400"}}

Custom pricing (39012f1)

A second proxy with per-token prices set on the deployment, called twice with a ~1.8k-token input (cost_openai_body.json) to see whether OpenAI reports cached tokens

model_list:
  - model_name: luna-priced
    litellm_params:
      model: openai/gpt-6-luna
      api_key: os.environ/OPENAI_API_KEY
      input_cost_per_token: 0.000001
      output_cost_per_token: 0.000002
      cache_read_input_token_cost: 0.0000001
      cache_creation_input_token_cost: 0.00000125

general_settings:
  master_key: os.environ/DECISIONS_PROOF_MASTER_KEY
=== call 1: curl -i localhost:4012/v1/decisions -d @cost_openai_body.json
HTTP/1.1 200 OK
x-litellm-response-cost: 0.001854
{"model":"gpt-6-luna","answers":[{"type":"predicate","name":"is_refund_request","probability":1.0}],"usage":{"input_tokens":1854,"input_tokens_details":{"cached_tokens":0,"cache_write_tokens":0},"output_tokens":0,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":1854}}
expected (input-cached-write)*1e-6 + cached*1e-7 + write*1.25e-6 + output*2e-6 = 0.001854000; proxy billed 0.001854000; match=True

=== call 2: curl -i localhost:4012/v1/decisions -d @cost_openai_body.json
HTTP/1.1 200 OK
x-litellm-response-cost: 0.001854
{"model":"gpt-6-luna","answers":[{"type":"predicate","name":"is_refund_request","probability":1.0}],"usage":{"input_tokens":1854,"input_tokens_details":{"cached_tokens":0,"cache_write_tokens":0},"output_tokens":0,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":1854}}
expected (input-cached-write)*1e-6 + cached*1e-7 + write*1.25e-6 + output*2e-6 = 0.001854000; proxy billed 0.001854000; match=True

Billing matches the configured rates. OpenAI and Typesafe both returned 0 cached tokens on every repeat (5 and 3 identical calls), so the cached-token path is covered by test_custom_token_pricing_bills_cached_decisions_input_tokens_once, which fails on 85f9121 and passes here

QA notes from the run:

  • A choice value of 1 reaches OpenAI as true
  • Choices true and "true" together are rejected for OpenAI too
  • Duplicate names: luna returns OpenAI's 400, jev returns 200
  • OpenAI reports output_tokens as 0 for luna
  • /v1/systemone usage has no total_tokens
  • One-option System One choice to luna: OpenAI 400 names choices
  • Jev sent no answer extras, so unit test covers fix

OpenAI key from litellm_settings (e465919)

The proxy runs with no OPENAI_API_KEY in its environment and the key set only through litellm_settings.openai_key, which other OpenAI calls already honor

model_list:
  - model_name: luna
    litellm_params:
      model: openai/gpt-6-luna

litellm_settings:
  openai_key: os.environ/DECISIONS_PROOF_OPENAI_KEY

general_settings:
  master_key: os.environ/DECISIONS_PROOF_MASTER_KEY

Before, at 39012f1, the provider never looked at litellm.openai_key

$ curl -sS -i localhost:4000/v1/decisions -H "Authorization: Bearer $LITELLM_MASTER_KEY" -H 'Content-Type: application/json' -d @body.json
HTTP/1.1 401 Unauthorized
x-litellm-response-cost: 0
{"error":{"message":"litellm.AuthenticationError: Missing API key for Decisions provider 'openai'\n\nLiteLLM: model group 'luna' failed with the error above. No fallback was attempted.","type":"authentication_error","param":null,"code":"401"}}

After, at e465919, the same request reaches OpenAI and gets a real answer

$ curl -sS -i localhost:4000/v1/decisions -H "Authorization: Bearer $LITELLM_MASTER_KEY" -H 'Content-Type: application/json' -d @body.json
HTTP/1.1 200 OK
x-litellm-response-cost: 1.68e-05
{"model":"gpt-6-luna","answers":[{"type":"predicate","name":"is_defect","probability":0.52}],"usage":{"input_tokens":168,"input_tokens_details":{"cached_tokens":0,"cache_write_tokens":0},"output_tokens":0,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":168}}

System One questions without instructions (e4081f7)

Same config as above. Before, at a2951b3, OpenAI rejected the translated request for the criteria-only choice and score questions, and the single-option question cost an upstream round trip

$ cat noinst.json
{"model": "luna", "state": "My new phone arrived with a cracked screen and I want my money back.", "questions": {"action": {"type": "choice", "criteria": {"refund": "Customer wants money back", "replace": "Customer wants a new unit", "escalate": null}}, "severity": {"type": "score", "criteria": ["cosmetic", "partly usable", "unusable"]}}}
$ curl -sS -i localhost:4000/v1/systemone -H "Authorization: Bearer $LITELLM_MASTER_KEY" -H 'Content-Type: application/json' -d @noinst.json
HTTP/1.1 400 Bad Request
x-litellm-response-cost: 0
{"error":{"message":"litellm.BadRequestError: OpenAIException - {\"error\":{\"message\":\"Missing required parameter: 'questions[0].instructions'.\",\"type\":\"invalid_request_error\",\"param\":\"questions[0].instructions\",\"code\":\"missing_required_parameter\"}}\n\nLiteLLM: model group 'luna' failed with the error above. No fallback was attempted.","type":"invalid_request_error","param":null,"code":"400"}}

$ cat oneopt.json
{"model": "luna", "state": "My new phone arrived with a cracked screen.", "questions": {"action": {"type": "choice", "instructions": "What should we do?", "criteria": {"refund": "Customer wants money back"}}}}
$ curl -sS -i localhost:4000/v1/systemone -H "Authorization: Bearer $LITELLM_MASTER_KEY" -H 'Content-Type: application/json' -d @oneopt.json
HTTP/1.1 400 Bad Request
x-litellm-response-cost: 0
{"error":{"message":"litellm.BadRequestError: OpenAIException - {\"error\":{\"message\":\"Invalid 'questions[0].choices': array too short. Expected an array with minimum length 2, but got an array with length 1 instead.\",\"type\":\"invalid_request_error\",\"param\":\"questions[0].choices\",\"code\":\"array_below_min_length\"}}\n\nLiteLLM: model group 'luna' failed with the error above. No fallback was attempted.","type":"invalid_request_error","param":null,"code":"400"}}

After, at e4081f7, the criteria-only questions get real answers from OpenAI and the single-option question gets a litellm 400 that says why

$ cat noinst.json
{"model": "luna", "state": "My new phone arrived with a cracked screen and I want my money back.", "questions": {"action": {"type": "choice", "criteria": {"refund": "Customer wants money back", "replace": "Customer wants a new unit", "escalate": null}}, "severity": {"type": "score", "criteria": ["cosmetic", "partly usable", "unusable"]}}}
$ curl -sS -i localhost:4000/v1/systemone -H "Authorization: Bearer $LITELLM_MASTER_KEY" -H 'Content-Type: application/json' -d @noinst.json
HTTP/1.1 200 OK
x-litellm-response-cost: 2.48e-05
{"model":"gpt-6-luna","answers":{"action":{"type":"choice","choice":"refund","confidence":1.0,"probabilities":{"refund":1.0,"replace":0.0,"escalate":0.0}},"severity":{"type":"score","score":1.17,"confidence":0.0,"legend":{"0":"cosmetic","1":"partly usable","2":"unusable"},"probabilities":{"0":0.29,"1":0.25,"2":0.46}}},"usage":{"input_tokens":248,"output_tokens":0}}

$ cat oneopt.json
{"model": "luna", "state": "My new phone arrived with a cracked screen.", "questions": {"action": {"type": "choice", "instructions": "What should we do?", "criteria": {"refund": "Customer wants money back"}}}}
$ curl -sS -i localhost:4000/v1/systemone -H "Authorization: Bearer $LITELLM_MASTER_KEY" -H 'Content-Type: application/json' -d @oneopt.json
HTTP/1.1 400 Bad Request
x-litellm-response-cost: 0
{"error":{"message":"litellm.BadRequestError: Decisions provider 'openai' cannot serve this request: OpenAI needs at least 2 choices or levels on every choice or score question\n\nLiteLLM: model group 'luna' failed with the error above. No fallback was attempted.","type":"invalid_request_error","param":null,"code":"400"}}

After merging main's provider config dispatch (48f1a9c)

Same luna config and bodies as the After runs above, on the tip that ports this PR onto #45130. Both routes still reach OpenAI and bill, and the litellm_settings key and single-option runs from the previous section were repeated here with the same output

=== systemone
HTTP/1.1 200 OK
x-litellm-response-cost: 3.9699999999999996e-05
{"model":"gpt-6-luna","answers":{"is_refund_request":{"type":"noul","noul":1.0},"urgency":{"type":"choice","choice":"high","confidence":1.0,"probabilities":{"low":0.0,"high":1.0}},"frustration":{"type":"score","score":0.96,"confidence":0.76,"legend":{"0":"calm","1":"mildly annoyed","2":"angry"},"probabilities":{"0":0.1,"1":0.84,"2":0.06}}},"usage":{"input_tokens":397,"output_tokens":0}}

=== decisions
HTTP/1.1 200 OK
x-litellm-response-cost: 4.01e-05
{"model":"gpt-6-luna","answers":[{"type":"predicate","name":"is_refund_request","probability":1.0},{"type":"choice","name":"needs_human","choice":true,"probabilities":[{"value":true,"probability":0.99},{"value":false,"probability":0.01}],"confidence":0.98},{"type":"score","name":"frustration","score":0.74,"probabilities":[{"value":0,"label":"calm","probability":0.26},{"value":1,"label":"mildly annoyed","probability":0.74},{"value":2,"label":"angry","probability":0.0}],"confidence":0.61}],"usage":{"input_tokens":401,"input_tokens_details":{"cached_tokens":0,"cache_write_tokens":0},"output_tokens":0,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":401}}

Type

New Feature

Caveats (if any)

Medium

  • OpenAI has not published Decisions API pricing yet
    • Cost uses gpt-6-luna token rates until it does
  • System One questions with a single choice or level get a 400 on openai deployments, since OpenAI needs at least 2

Low

Final Attestation

  • The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR

Note

Medium Risk
Touches request routing, cross-format translation, and billing for a new provider API; mis-translation or usage mapping could cause wrong answers or spend, but behavior is heavily tested.

Overview
Adds OpenAI as a Decisions provider and splits the proxy into two request shapes: /v1/systemone (state + keyed noul/choice/score questions) and /v1/decisions (OpenAI-style input, question list, safety_identifier, including images).

decisions / adecisions now validate either format, reject mixing state and input, normalize through a shared IR, let each provider build the upstream body via transform_decisions_request, then map the IR response back to System One or OpenAI answers. OpenAIDecisionsConfig targets https://api.openai.com/v1/decisions; Jev-style providers still use /v1/systemone wire bodies. Unsupported cases (e.g. images to text-only providers, single-option choice/score for OpenAI) fail with 400 before HTTP.

Cost and logging understand OpenAIDecisionResponse usage (cached / cache-write input tokens). gpt-6-luna pricing lists /v1/decisions. Gateway allowlists, lazy routes, auth, integration tests, and the playground default shift legacy Jev traffic to /v1/systemone; /v1/decisions stays available for pass-through when configured.

Reviewed by Cursor Bugbot for commit c19ada6. Bugbot is set up for automated code reviews on this repo. Configure here.

…format at /v1/decisions

The System One request format moves to /v1/systemone and /systemone. /v1/decisions and
/decisions now accept the OpenAI Decisions API format, translate it into a System One
request, route it through the same pipeline, and translate the answers back.
/v1/decisions accepted up to 200 questions, but the System One request it translates to takes at most 128, so 129 to 200 questions failed with an internal validation error. Both now share MAX_DECISION_QUESTIONS.
System One requests to openai/ models are translated to OpenAI's Decisions shape on the way out and OpenAI's answers are translated back, so both /v1/systemone and /v1/decisions can route to gpt-6-luna.
@greptile-apps

greptile-apps Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 5/5

[Medium risk] Adds OpenAI as a decisions provider with shared format transformation.

The PR appears safe to merge; no new actionable issue or outstanding previous finding remains

What we checked:

  • Frozen responses keep guardrail results: attach_guardrail_information returns a copy with one added field. Both empty and populated results use this path

Summary

This PR adds OpenAI as a Decisions provider and gives both public request formats a shared provider path. Since the previous review, it allows extra fields on OpenAIDecisionResponse so the proxy can attach requested guardrail results

  • Adds a regression test for include_guardrail_response on both routes
  • The previous provider-helper location finding is fixed. The helpers now live under llms/
  • The previous answer-extra finding is fixed. The shared format preserves extras inside requested answers
  • mateo-berri accepted the route change because the old behavior shipped only in a development release. Greptile withdrew that compatibility finding
  • The PR explicitly accepts dropping unasked answer keys, interim OpenAI pricing, single-option rejection, numeric-to-boolean coercion, and boolean/string choice collisions. These are not new findings

Reviews (10) · Last reviewed commit: "Merge remote-tracking branch 'origin/lit..." · Reviewed by Greptile

Comment thread litellm/decisions/openai_transformation.py Outdated
@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai please re-review now that the OpenAI wire translation lives under llms/openai as you suggested

@codspeed

codspeed Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_openai_decisions_provider (7ac5bde) with main (8fc31b1)

Open in CodSpeed

Comment thread litellm/proxy/decisions_endpoints/endpoints.py Outdated
@codecov

codecov Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.80198% with 21 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
litellm/llms/base_llm/decisions/transformation.py 92.38% 8 Missing ⚠️
litellm/llms/openai/decisions/transformation.py 93.69% 7 Missing ⚠️
litellm/decisions/main.py 91.66% 4 Missing ⚠️
litellm/cost_calculator.py 87.50% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

…s IR

System One and OpenAI-format bodies now convert to one internal representation, and each provider translates it to its own wire format. OpenAI deployments receive the caller's messages, images, names, typed choice values, level descriptions and safety_identifier unchanged. System One providers return a 400 for image input. Cached and cache-write tokens are priced on both response shapes, and provider response extras survive the round trip.
@mateo-berri mateo-berri changed the title feat(decisions): add OpenAI as a Decisions provider feat(decisions): add OpenAI as a Decisions provider behind a shared decisions format Oct 8, 2026
@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai please re-review the latest commit, which routes both Decisions request formats through one shared format

@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

Comment thread litellm/llms/base_llm/decisions/transformation.py
@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

mateo-berri added a commit that referenced this pull request Oct 8, 2026
Backport of #45214 to stable/1.104.x.
Cherry-picked from 1348de8 (litellm_openai_decisions_provider).

(cherry picked from commit 1348de8)
@mateo-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Answer name is required on parse
    • Defaulted OpenAI answer name to None so provider responses that omit the key on unnamed answers parse instead of raising.

Create PR

Or push these changes by commenting:

@cursor push 6d895d3d5b
Preview (6d895d3d5b)
diff --git a/litellm/types/decisions.py b/litellm/types/decisions.py
--- a/litellm/types/decisions.py
+++ b/litellm/types/decisions.py
@@ -235,7 +235,7 @@
 
 class OpenAIPredicateAnswer(LiteLLMPydanticObjectBase):
     type: Literal["predicate"] = "predicate"
-    name: str | None
+    name: str | None = None
     probability: float
 
     model_config = ConfigDict(frozen=True)
@@ -250,7 +250,7 @@
 
 class OpenAIChoiceAnswer(LiteLLMPydanticObjectBase):
     type: Literal["choice"] = "choice"
-    name: str | None
+    name: str | None = None
     choice: str | bool
     probabilities: tuple[OpenAIChoiceProbability, ...]
     confidence: float
@@ -268,7 +268,7 @@
 
 class OpenAIScoreAnswer(LiteLLMPydanticObjectBase):
     type: Literal["score"] = "score"
-    name: str | None
+    name: str | None = None
     score: float
     probabilities: tuple[OpenAIScoreProbability, ...]
     confidence: float
@@ -278,7 +278,7 @@
 
 class OpenAIRefusalAnswer(LiteLLMPydanticObjectBase):
     type: Literal["refusal"] = "refusal"
-    name: str | None
+    name: str | None = None
 
     model_config = ConfigDict(frozen=True)
 

diff --git a/tests/unit/llms/openai/decisions/test_openai_decisions_transformation.py b/tests/unit/llms/openai/decisions/test_openai_decisions_transformation.py
--- a/tests/unit/llms/openai/decisions/test_openai_decisions_transformation.py
+++ b/tests/unit/llms/openai/decisions/test_openai_decisions_transformation.py
@@ -100,6 +100,36 @@
     assert ir_to_openai_response(parsed, ir, "requested").model_dump(mode="json") == _OPENAI_RESPONSE
 
 
+def test_parse_response_accepts_answers_that_omit_name() -> None:
+    ir: Final = _openai_ir(_OPENAI_REQUEST)
+    payload: Final = {
+        **_OPENAI_RESPONSE,
+        "answers": [
+            {"type": "predicate", "probability": 0.95},
+            {
+                "type": "choice",
+                "choice": True,
+                "probabilities": [{"value": True, "probability": 0.9}, {"value": "escalate", "probability": 0.1}],
+                "confidence": 0.8,
+            },
+            {
+                "type": "score",
+                "score": 0.7,
+                "probabilities": [
+                    {"value": 0, "label": "minor", "probability": 0.3},
+                    {"value": 1, "label": "major", "probability": 0.7},
+                ],
+                "confidence": 0.6,
+            },
+            {"type": "refusal"},
+        ],
+    }
+
+    parsed: Final = OPENAI_DECISIONS_ENDPOINT.parse_response(payload, ir)
+
+    assert ir_to_openai_response(parsed, ir, "requested").model_dump(mode="json") == _OPENAI_RESPONSE
+
+
 def test_answers_openai_did_not_return_are_refusals() -> None:
     ir: Final = _openai_ir(_OPENAI_REQUEST)
     payload: Final = {**_OPENAI_RESPONSE, "answers": [_PREDICATE_ANSWER]}

You can send follow-ups to the cloud agent here.

Comment thread litellm/types/decisions.py
…point

# Conflicts:
#	litellm/types/decisions.py
…ns_provider

Ports the openai Decisions provider and the shared IR onto main's BaseDecisionsConfig and shared HTTP handler dispatch
@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

… litellm_openai_decisions_provider

# Conflicts:
#	litellm/proxy/decisions_endpoints/endpoints.py
#	litellm/types/decisions.py
@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@mateo-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit c19ada6. Configure here.

mateo-berri added a commit that referenced this pull request Oct 8, 2026
…ns when requested

Backport of the #45184 and #45214 guardrail fix.
Cherry-picked from 855f0bb (litellm_systemone_endpoint) as merged into #45214 at c19ada6 (litellm_openai_decisions_provider).
mateo-berri added a commit that referenced this pull request Oct 8, 2026
…ns when requested

Backport of the #45184 and #45214 guardrail fix.
Cherry-picked from 855f0bb (litellm_systemone_endpoint) as merged into #45214 at c19ada6 (litellm_openai_decisions_provider).
…ons_provider

# Conflicts:
#	litellm/proxy/decisions_endpoints/endpoints.py
#	litellm/types/decisions.py
#	tests/unit/proxy/decisions_endpoints/test_endpoints.py
@mateo-berri
mateo-berri enabled auto-merge (squash) October 8, 2026 04:51
@mateo-berri
mateo-berri merged commit 33d908e into main Oct 8, 2026
103 of 105 checks passed
@mateo-berri
mateo-berri deleted the litellm_openai_decisions_provider branch October 8, 2026 04:57
mateo-berri added a commit that referenced this pull request Oct 8, 2026
…and the openai Decisions provider to rc/1.105.0 (#44236, #45184, #45214) (#45189)

* feat(decisions): add unified /v1/decisions endpoint for Jev-compatible providers (#44236)

Backport of #44236 to rc/1.105.0.
Cherry-picked from 8b1990b (main).

* feat(decisions): serve System One format at /v1/systemone and OpenAI format at /v1/decisions

Backport of #45184 to rc/1.105.0.
Cherry-picked from 3339469 (litellm_systemone_endpoint). The playground System One tab is not on this line, so its UI hunks are dropped.

* fix(decisions): import assert_never from typing_extensions for Python 3.10

Backport of the #45184 follow-up to rc/1.105.0.
Cherry-picked from 29e7154 (litellm_systemone_endpoint).

* fix(decisions): cap OpenAI-format questions at the System One limit

Backport of the #45184 follow-up to rc/1.105.0.
Cherry-picked from 7ef1329 (litellm_systemone_endpoint).

* fix(decisions): return 400 for bodies that are not JSON

Backport of the #45184 follow-up to rc/1.105.0.
Cherry-picked from b6bb885 (litellm_systemone_endpoint).

* test(decisions): send System One bodies to /v1/systemone in integration tests

Backport of the #45184 follow-up to rc/1.105.0.
Cherry-picked from 77872f6 (litellm_systemone_endpoint).

* feat(decisions): add OpenAI as a Decisions provider

System One requests to openai/ models are translated to OpenAI's Decisions shape on the way out and OpenAI's answers are translated back, so both /v1/systemone and /v1/decisions can route to gpt-6-luna.

(cherry picked from commit b94bbbc)

* refactor(decisions): move the OpenAI wire translation into llms/openai

(cherry picked from commit d209a85)

* feat(decisions): route both request formats through a shared decisions IR

System One and OpenAI-format bodies now convert to one internal representation, and each provider translates it to its own wire format. OpenAI deployments receive the caller's messages, images, names, typed choice values, level descriptions and safety_identifier unchanged. System One providers return a 400 for image input. Cached and cache-write tokens are priced on both response shapes, and provider response extras survive the round trip.

(cherry picked from commit 0dd207f)

* fix(decisions): keep provider fields inside System One answers

(cherry picked from commit 1348de8)

* fix(decisions): load on Python 3.10 and 3.11 and bill cached tokens once under custom pricing

Decisions IR dataclasses used a mappingproxy default, which is only hashable from Python 3.12, so import litellm failed on 3.10 and 3.11. Decisions usage now reaches cost_per_token as a Usage with prompt_tokens_details, so custom pricing no longer adds cached and cache-write tokens on top of input_tokens that already include them

(cherry picked from commit 39012f1)

* fix(decisions): honor litellm OpenAI key and base settings for the openai provider

OpenAI decisions now resolve the key from litellm.api_key, litellm.openai_key, then OPENAI_API_KEY, and the base from litellm.api_base, OPENAI_BASE_URL, then OPENAI_API_BASE, matching other OpenAI calls. Providers own their configured key and base lookup through the endpoint config.

(cherry picked from commit e465919)

* test(decisions): clear every OpenAI base setting in the proxy OpenAI deployment test

(cherry picked from commit a2951b3)

* fix(decisions): send OpenAI instructions for System One questions without them and reject single-option questions

(cherry picked from commit e4081f7)

* fix(decisions): return guardrail_information on OpenAI-format decisions when requested

Backport of the #45184 and #45214 guardrail fix.
Cherry-picked from 855f0bb (litellm_systemone_endpoint) as merged into #45214 at c19ada6 (litellm_openai_decisions_provider).

---------

Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
mateo-berri added a commit that referenced this pull request Oct 8, 2026
…ble_1_104_x

feat(decisions): backport /v1/systemone, OpenAI-format /v1/decisions and the OpenAI Decisions provider to stable/1.104.x for v1.104.2 (#44236, #45184, #45214)
@mateo-berri mateo-berri mentioned this pull request Oct 8, 2026
1 task done
doonga pushed a commit to greyrock-labs/home-ops that referenced this pull request Oct 8, 2026
…04.2) (#374)

This PR contains the following updates:

| Package | Update | Change |
|---|---|---|
| [ghcr.io/berriai/litellm](https://images.chainguard.dev/directory/image/wolfi-base/overview) ([source](https://github.com/BerriAI/litellm)) | patch | `v1.104.0` → `v1.104.2` |

---

### Release Notes

<details>
<summary>BerriAI/litellm (ghcr.io/berriai/litellm)</summary>

### [`v1.104.2`](https://github.com/BerriAI/litellm/releases/tag/v1.104.2)

[Compare Source](BerriAI/litellm@v1.104.1...v1.104.2)

#### Verify Docker Image Signature

All LiteLLM Docker images are signed with [cosign](https://docs.sigstore.dev/cosign/overview/). Every release is signed with the same key introduced in [commit `0112e53`](BerriAI/litellm@0112e53).

**Verify using the pinned commit hash (recommended):**

A commit hash is cryptographically immutable, so this is the strongest way to ensure you are using the original signing key:

```bash
cosign verify \
  --key https://raw.githubusercontent.com/BerriAI/litellm/0112e53046018d726492c814b3644b7d376029d0/cosign.pub \
  ghcr.io/berriai/litellm:v1.104.2
```

**Verify using the release tag (convenience):**

Tags are protected in this repository and resolve to the same key. This option is easier to read but relies on tag protection rules:

```bash
cosign verify \
  --key https://raw.githubusercontent.com/BerriAI/litellm/v1.104.2/cosign.pub \
  ghcr.io/berriai/litellm:v1.104.2
```

Expected output:

```
The following checks were performed on each of these signatures:
  - The cosign claims were validated
  - The signatures were verified against the specified public key
```

***

#### What's Changed

- feat(decisions): backport /v1/systemone, OpenAI-format /v1/decisions and the OpenAI Decisions provider to stable/1.104.x for v1.104.2 ([#&#8203;44236](BerriAI/litellm#44236), [#&#8203;45184](BerriAI/litellm#45184), [#&#8203;45214](BerriAI/litellm#45214)) by [@&#8203;mateo-berri](https://github.com/mateo-berri) in [#&#8203;45190](BerriAI/litellm#45190)

**Full Changelog**: <BerriAI/litellm@v1.104.1...v1.104.2>

### [`v1.104.1`](https://github.com/BerriAI/litellm/releases/tag/v1.104.1)

[Compare Source](BerriAI/litellm@v1.104.0...v1.104.1)

#### Verify Docker Image Signature

All LiteLLM Docker images are signed with [cosign](https://docs.sigstore.dev/cosign/overview/). Every release is signed with the same key introduced in [commit `0112e53`](BerriAI/litellm@0112e53).

**Verify using the pinned commit hash (recommended):**

A commit hash is cryptographically immutable, so this is the strongest way to ensure you are using the original signing key:

```bash
cosign verify \
  --key https://raw.githubusercontent.com/BerriAI/litellm/0112e53046018d726492c814b3644b7d376029d0/cosign.pub \
  ghcr.io/berriai/litellm:v1.104.1
```

**Verify using the release tag (convenience):**

Tags are protected in this repository and resolve to the same key. This option is easier to read but relies on tag protection rules:

```bash
cosign verify \
  --key https://raw.githubusercontent.com/BerriAI/litellm/v1.104.1/cosign.pub \
  ghcr.io/berriai/litellm:v1.104.1
```

Expected output:

```
The following checks were performed on each of these signatures:
  - The cosign claims were validated
  - The signatures were verified against the specified public key
```

***

#### What's Changed

- chore(release): refresh dependencies on stable/1.104.x and cut 1.104.1 by [@&#8203;yuneng-berri](https://github.com/yuneng-berri) in [#&#8203;44777](BerriAI/litellm#44777)
- chore(docker): bump pgbouncer to 1.26.0 by [@&#8203;devin-ai-integration](https://github.com/devin-ai-integration)\[bot] in [#&#8203;45012](BerriAI/litellm#45012)

**Full Changelog**: <BerriAI/litellm@v1.104.0...v1.104.1>

</details>

---

### Configuration

📅 **Schedule**: (in timezone America/New_York)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Mend Renovate CLI](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0NC4xMzMuMCIsInVwZGF0ZWRJblZlciI6IjQ0LjEzMy4wIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6WyJyZW5vdmF0ZS9jb250YWluZXIiLCJ0eXBlL3BhdGNoIl19-->

Reviewed-on: https://git.greyrock.io/todd/home-ops/pulls/374
mateo-berri added a commit that referenced this pull request Oct 8, 2026
…pes left dead by #45214 (#45442)

#45214 routed both decision formats through the shared decisions IR in
litellm/llms/base_llm/decisions/transformation.py and litellm/types/decisions.py.
That left litellm/llms/base_llm/decisions/systemone.py (to_system_one_request,
question_keys, to_decisions_response, the SystemOne* models and their adapter)
and litellm/types/openai_decisions.py with no importer outside their own unit
tests, so both modules and both test files go.

Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
DeoLeung pushed a commit to aiyou178/litellm that referenced this pull request Oct 11, 2026
…pes left dead by BerriAI#45214 (BerriAI#45442)

BerriAI#45214 routed both decision formats through the shared decisions IR in
litellm/llms/base_llm/decisions/transformation.py and litellm/types/decisions.py.
That left litellm/llms/base_llm/decisions/systemone.py (to_system_one_request,
question_keys, to_decisions_response, the SystemOne* models and their adapter)
and litellm/types/openai_decisions.py with no importer outside their own unit
tests, so both modules and both test files go.

Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
(cherry picked from commit 0e48048)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants