blob: a0a11b04520584d757fb3dd48e4061f7d1dbbcc1 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
use super::{ADAPTER, Codex, login};
use futures_util::future::BoxFuture;
use maka_agent_facts::configuration::ApiProtocol;
use maka_agent_facts::configuration::ModelInfo;
use maka_agent_facts::execution::ThinkingLevel;
use maka_agent_ports::model::Credentials;
use maka_agent_ports::model::ProviderKind;
use maka_agent_ports::provider::Connection;
use maka_agent_ports::provider::Context;
use maka_agent_ports::provider::Definition;
use maka_agent_ports::provider::Descriptor;
use maka_agent_ports::provider::Discovery;
use maka_agent_ports::provider::Error;
use maka_agent_ports::provider::Model;
use maka_agent_ports::provider::Provider;
use maka_agent_ports::provider::Resolve;
use maka_agent_ports::provider::Verification;
use maka_agent_ports::provider::authentication::Authenticate;
use maka_agent_ports::provider::authentication::Credential;
use maka_agent_ports::provider::authentication::Method;
use maka_capabilities::http;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::sync::Arc;
const ENDPOINT: &str = "https://chatgpt.com/backend-api/codex";
#[derive(Deserialize, Serialize, schemars::JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct Configuration {
base_url: String,
}
#[derive(Deserialize, Serialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
struct LoginInput {}
pub(super) fn configuration(connection: &Connection) -> Result<String, Error> {
let configuration: Configuration = serde_json::from_value(connection.configuration.clone())
.map_err(|_| Error::Invalid("invalid subscription configuration".into()))?;
if configuration.base_url.trim_end_matches('/') != ENDPOINT {
return Err(Error::Invalid(
"subscription endpoint cannot be overridden".into(),
));
}
Ok(configuration.base_url)
}
impl Codex {
pub fn definition(&self) -> Result<Definition, maka_capabilities::Error> {
Definition::new(
Descriptor {
label: "ChatGPT Subscription".into(),
custom_endpoint: false,
configuration_schema: serde_json::to_value(schemars::schema_for!(Configuration))
.map_err(|e| maka_capabilities::Error::Invalid(e.to_string()))?,
configuration_defaults: json!({ "baseUrl": ENDPOINT }),
authentication: vec![Method {
id: "chatgpt".into(),
label: "Sign in with ChatGPT".into(),
input_schema: serde_json::to_value(schemars::schema_for!(LoginInput))
.map_err(|e| maka_capabilities::Error::Invalid(e.to_string()))?,
interactive: true,
}],
anonymous: false,
discovery: true,
},
Arc::new(Self::default()),
)
.map_err(|e| maka_capabilities::Error::Invalid(e.to_string()))
}
}
impl Provider for Codex {
fn resolve(&self, request: Resolve) -> BoxFuture<'_, Result<Model, Error>> {
Box::pin(async move {
let base_url = configuration(&request.connection)?;
let mut info = match &request.overrides {
Some(overrides) => overrides.apply(&request.model),
None => request.model,
};
// Existing saved connections may predate discovery of this fact.
if info.max_output_tokens.is_none() {
info.max_output_tokens = super::inventory::output_capacity(&info.id)?;
}
if info
.api_protocol
.is_some_and(|wire| wire != ApiProtocol::OpenaiResponses)
{
return Err(Error::Invalid(
"subscription requires the Responses protocol".into(),
));
}
let thinking_levels = request
.overrides
.as_ref()
.and_then(|o| o.thinking_levels.clone())
.or_else(|| info.thinking_levels.clone())
.unwrap_or_default();
let level = request.thinking_level.or_else(|| {
request
.overrides
.as_ref()
.and_then(|o| o.default_thinking_level)
});
if level.is_some_and(|level| !thinking_levels.contains(&level)) {
return Err(Error::Invalid(
"unsupported subscription thinking level".into(),
));
}
let parallel = info
.capabilities
.and_then(|c| c.parallel_tool_calls)
.unwrap_or(true);
let mut options = json!({"openai":{"store":false,"parallelToolCalls":parallel}});
if let Some(level) = level {
options["openai"]["reasoningEffort"] = match level {
ThinkingLevel::Off => json!("none"),
level => {
serde_json::to_value(level).map_err(|e| Error::Invalid(e.to_string()))?
}
};
}
let summary = info
.supports_reasoning_summary
.unwrap_or(info.capabilities.and_then(|c| c.reasoning) != Some(false));
if summary && level != Some(ThinkingLevel::Off) {
options["openai"]["reasoningSummary"] = json!("auto");
}
if summary || level.is_some() {
options["openai"]["forceReasoning"] = json!(true);
}
let model = Model {
main_output_limit: request
.overrides
.as_ref()
.and_then(|o| o.max_output_tokens)
.map(|limit| {
info.max_output_tokens
.map_or(limit, |capacity| limit.min(capacity))
}),
adapter: request
.overrides
.as_ref()
.and_then(|o| o.adapter.clone())
.unwrap_or_else(|| ADAPTER.into()),
protocol: ProviderKind::OpenaiResponses,
base_url,
info,
thinking_levels,
provider_options: options,
};
model.validate()?;
Ok(model)
})
}
fn authorize(
&self,
connection: Connection,
credential: Option<Credential>,
session_id: String,
) -> BoxFuture<'_, Result<Credentials, Error>> {
Box::pin(async move {
configuration(&connection)?;
let tokens = login::Tokens::read(&credential.ok_or(Error::AuthenticationRequired)?)?;
let headers = super::request_headers(&tokens.access_token, &session_id)
.map_err(|_| Error::Invalid("invalid subscription request identity".into()))?;
Ok(Credentials::RequestHeaders(headers))
})
}
fn authenticate(
&self,
request: Authenticate,
context: Context,
) -> BoxFuture<'_, Result<Credential, Error>> {
Box::pin(async move {
serde_json::from_value::<LoginInput>(request.input.clone())
.map_err(|_| Error::Invalid("invalid subscription login input".into()))?;
login::authenticate(request, context).await
})
}
fn refresh(
&self,
connection: Connection,
credential: Credential,
context: Context,
) -> BoxFuture<'_, Result<Credential, Error>> {
Box::pin(async move {
configuration(&connection)?;
login::refresh(credential, context).await
})
}
fn discover(
&self,
request: Discovery,
context: Context,
) -> BoxFuture<'_, Result<Vec<ModelInfo>, Error>> {
Box::pin(async move {
let Discovery {
connection,
credential,
request_headers,
} = request;
let base_url = configuration(&connection)?;
let tokens = login::Tokens::read(&credential.ok_or(Error::AuthenticationRequired)?)?;
let mut headers = super::request_headers(&tokens.access_token, &connection.id)
.map_err(|_| Error::Invalid("invalid connection identity".into()))?;
for (name, value) in request_headers {
if headers
.iter()
.any(|(key, existing)| key.eq_ignore_ascii_case(&name) && existing != &value)
{
return Err(Error::Invalid(
"request headers conflict with provider authentication".into(),
));
}
headers.retain(|key, _| !key.eq_ignore_ascii_case(&name));
headers.insert(name, value);
}
let response = context
.transport
.request(http::Request {
url: format!(
"{}/models?client_version=1.0.0",
base_url.trim_end_matches('/')
),
method: http::Method::Get,
headers: headers.into_iter().collect(),
body: vec![],
})
.await
.map_err(|_| Error::Transport("model discovery failed".into()))?;
let result = async {
if !(200..300).contains(&response.head.status) {
return Err(Error::Http(response.head.status));
}
let mut bytes = Vec::new();
while let Some(chunk) = response
.body
.next()
.await
.map_err(|_| Error::Transport("model inventory read failed".into()))?
{
bytes.extend_from_slice(&chunk);
}
super::decode_model_inventory(&bytes)
}
.await;
response.body.cancel();
response
.body
.close()
.await
.map_err(|_| Error::Transport("inventory cleanup failed".into()))?;
result
})
}
fn verify(&self, request: Verification, context: Context) -> BoxFuture<'_, Result<(), Error>> {
Box::pin(async move {
self.resolve(Resolve {
connection: request.connection.clone(),
model: request.model.clone(),
overrides: request.overrides,
thinking_level: None,
})
.await?;
let models = self
.discover(
Discovery {
connection: request.connection,
credential: request.credential,
request_headers: request.request_headers,
},
context,
)
.await?;
if models.iter().any(|model| model.id == request.model.id) {
Ok(())
} else {
Err(Error::Rejected(
"model is absent from the account inventory".into(),
))
}
})
}
}