Changelog¶
All notable changes to this project are documented here. The format follows Keep a Changelog, and the project uses Semantic Versioning.
[Unreleased]¶
[0.4.0] - 2026-10-16¶
Added¶
- A default workspace:
FortiAIGate(..., workspace=)(orFAIG_WORKSPACE) sends every call made withoutws_idto that workspace, and a workspace login (role notorg_admin/super_admin) defaults to its only workspace, so it works without passingws_ideverywhere.role,org_scopeanddefault_workspace()report it; an org admin with noworkspacekeeps the admin paths. The role is read once (GET /api/v1/user). blocked_requests(since=, until=, range=, max_items=, ws_id=): blocked requests with the prompt, the detectors that fired and why, and the virtual key that sent it (id, status, owner name and email, looked up from the log'skeyAlias; two keys with one name are told apart by their preview). ReturnsBlockedRequestrecords, flat enough to map onto a SOAR alert;sincealone is a poller's last-pull time.pyfortiaigate config:config initasks for the address, login, workspace and virtual key (password and key typed hidden, Enter keeps a saved value), logs in and checks the key, then writes~/.config/pyfortiaigate/.envwith mode 600; a refused login is never saved. Flags and--password-stdinanswer without a terminal.config(config list) shows each setting, its value (secrets masked) and where it came from;config set,config unsetandconfig pathedit and locate the file, keeping its comments.pyfortiaigate.settings: the one settings reader, used by the command line and the workshop scripts.load_settings()returns the resolvedFAIG_*values with their sources; it never changesos.environ.FortiAIGate.from_env(env)takes a mapping, such asload_settings().chatprints the HTTP status and whether the prompt was answered, blocked or refused, and exits 0, 3 or 1 to match.--repeat Nsends a burst and reports each request (a rate limit shows as 429 rows);--whynames the detectors a blocked prompt matched, from its traffic log record.--modeldefaults toFAIG_MODEL, else the first model the key may call;config initsaves the key's model so no request is spent on that lookup.scenarios CATALOG: send each prompt of a JSON catalog and compare blocked or allowed with itsexpect(PASS, MISMATCH, or ERROR when the gateway refused), with--use-case,--scannerand--verbose. Exit 0, 3 or 1. The workshop's catalog isexamples/workshop/scenarios.json.- A global
--urlflag.
Changed¶
- Settings resolve flag >
FAIG_*environment variable > settings file > default, and the file is read in the python-dotenv format (quotes,export, inline#comments, escapes in double quotes).--env-file=pathworks as well as--env-file path. chat --jsonprints a result (result,status,text, and the rawcompletion), or a list of them with--repeat, instead of the bare completion.-
The workshop's
lab.pyreads settings throughload_settings(); theenv.examplefile is gone (pyfortiaigate config initreplaces it). -
replace_virtual_keyis nowrenew_virtual_key(key_id, expires_in_days=30), returningRenewed(wasReplaced). It no longer creates a new key: an expired key becomes active again when its expiry moves into the future (live-verified on 8.0.2), so it is extended and then rotated in place, keeping its id, name, limits, policies and model access. A revoked or suspended key is refused. Thename,revoke_old,allow_revokedandallow_uncopiedarguments are gone, as isReplaced.not_copied.
Fixed¶
update_virtual_key(policy_ids=...)andensure_virtual_keyon a key with organization-assigned policies: the update now sends those policies back (leaving one out reads as detaching it, which only an org admin may do: 422), andensure_virtual_keycompares only the key's own policies.VirtualKeydeclareshiddenByProject, which 8.0.2 keys now carry.
Removed¶
pyfortiaigate.cli.load_envandpyfortiaigate.cli.default_env_file: usepyfortiaigate.settings.load_settingsanddefault_settings_file.
[0.3.2] - 2026-10-15¶
Added¶
replace_virtual_key(key_id, ...): a working replacement for a key, and its new secret. 8.0.2 rotates only an active key (an expired, revoked or suspended one answers "cannot be rotated: mint a new key instead"), so an active key is rotated in place and any other is reissued as a new key in the same workspace with its name, limits, policies, whole-provider access and lifetime. It fails closed, creating nothing, for a revoked key (allow_revoked) and for a key with model groups or per-model provider limits that a replacement could not inherit (allow_uncopied). ReturnsReplaced.
Fixed¶
test_guardrailandfaig scanon builds without the guardrail tester: later 8.0.2 builds droppedPOST .../guardrails/test(it answers 405). There the guardrail is scanned one custom detector at a time with the saved-detector test (POST .../detectors/{uuid}/test); a built-in detector cannot be tested offline on those builds, so its row saysstatus: "untested"andfaig scanpoints atchat. Builds that still have the tester use it, built-ins included.playgroundandfaig try-regexon builds without the unsaved-detector test (POST .../detectors/test, also gone): a regex body is matched locally, as the console's Playground does, and flaggedcheckedLocally; a DLP or AI body raises.- Resolving a detector by name inside a workspace read the org-only built-in
list, so a workspace member got HTTP 403 from
test_detector("<name>")and every other call that takes a detector name. It reads the workspace list now.
Changed¶
- Workshop
03_scan_first.pyscans with the student's own DLP detector (test_detector), which every build can test without a model. key_id_from_secretraisesValueErrorfor anything butfaig_<32 hex>_...instead of returning a made-up id.- A key name shared by several keys resolves to the single active one (a renewed key keeps its name); several active keys with one name are still refused.
[0.3.1] - 2026-10-13¶
Security¶
- The command line prints text from the server (traffic-log prompts and
answers, detector, guardrail and workspace names) as text: control
characters are shown escaped (
\x1b) instead of reaching the terminal as escape sequences, and rich markup in a name ([red]...) is shown, not applied. 0.3.0 passed both through.
Added¶
- Terminal screenshots in the command-line guide and the README, made by
scripts/cli_screenshots.pyfrom made-up data.
[0.3.0] - 2026-10-13¶
Added¶
pip install "pyfortiaigate[cli]": with rich installed, the command line draws coloured tables, panels and bar charts in a terminal. Pipes, and--plainorFAIG_CLI_STYLE=plain, get plain aligned text; the library itself still needs nothing.- New commands:
dashboard(traffic, blocks, blocks per detector, spend by model and a daily trend on one screen),trend usage|cost(a bar per hour, day, week or month),try-regex(a regex detector in the Playground, matches highlighted, nothing saved),check-key,builtinsandmembers. logs --followprints new traffic as it arrives.FAIG_WSis the default--ws(--ws ''for org-wide), and--json/--plainwork after the command as well as before it.
Changed¶
- Every command's output is easier to read: plain-English headers, names
instead of ids (a guardrail's detectors, a deleted key), money as
$, times as local time or "3h ago", verdicts and statuses coloured, and a share bar inusage/costrankings. logshows a summary (verdict, prompt, answer, tokens, cost and what matched) instead of the raw record;--jsonstill prints the record.whoamishows a summary instead of JSON (--jsonfor the raw user).scanstarts withVerdict: BLOCK(wasfinalDisposition: block).
Fixed¶
workspaces --mineprinted columns the endpoint does not return (all-); it now shows your role, your key count and your budget.
[0.2.2] - 2026-10-13¶
Added¶
- The command line reads
~/.config/pyfortiaigate/.env(orFAIG_ENV_FILE) when--env-fileis not given, so settings live in one place and every command finds them. A.envin the current directory is still never read, and a settings file other users can read gets a warning. - Tests and docs: a URL with any path (the console's
/ui, the gateway's/ai/openai/v1/chat/completions) is reduced to its host, as it always was. builtin_detectors(ws_id): the workspace path, which a workspace member can call.pyfortiaigate scannames each detector instead of printing its id.examples/workshop/: six short scripts for a hands-on lab (red-team a key, scan without a model call, blocks per detector, spend by model, a regex detector in the Playground), five with a FIXME to finish.
Fixed¶
-
Docs:
traffic_log()names the matching detector inviolation;detector,guardrailandpolicycome back empty on 8.0.2. -
Docs: types in method signatures (
-> User,list[Invitation],WsRef, ...) link to their entries; the docs build now fails on any reference that does not resolve.
[0.2.1] - 2026-10-13¶
Added¶
- Every method now declares what it returns: about 80
TypedDictresponse types (UsageSummary,Rankings,Breakdown,Analytics,TrafficLogPage,DetectorTest,GuardrailTest,NewVirtualKey,ChatCompletion, ...), recorded from a live 8.0.2 appliance and exported from the package. The docs list every field with its type and meaning, and the Actions page links each method to the type it returns. - A live conformance test fails when the appliance returns a field the declared type lacks, so the documented fields stay complete.
invitations(),invitation_by_email()andrevoke_invitation().- Org-level calls verified live and typed:
invite(Invitation),accept_invite(AcceptedInvite),make_workspace_admin(User),create_workspace(Workspace); an opt-in live test (FAIG_ORG_WRITES,FAIG_TEST_INVITEE) runs the whole invite, account and workspace lifecycle and cleans up.
Changed¶
delete_detector,delete_guardrail,delete_policy,delete_virtual_key,delete_user,delete_workspaceandwithdrawreturn None (the appliance answers with an empty body);accept_invitereturns{}rather than None for an empty body.create_workspaceanddelete_workspacekeep the workspace-name cache current.my_workspaces()is annotated aslist[MyWorkspace](its real shape), notlist[Workspace].
[0.2.0] - 2026-10-13¶
Added¶
- Usage, cost and analytics reports, org-wide or per workspace:
usage_summary/trend/rankings/breakdown,cost_summary/trend/rankings/breakdown,analytics;rank_itemsandbreakdown_itemsread the rows whatever shape the API answers in. Allowed values are checked before sending. - Traffic logs:
traffic_logs()iterator over the API cursor,traffic_logs_page,traffic_log(uuid)(cost, prompt, verdict, detector) and filter options; ICAP logs the same way. Filters are validated, since the API silently ignores unknown names and operators and returns every row. - Workspace-path writes:
delete_detector(..., ws_id),update_policy,withdraw(cancel a pending submission), andws_id=on every virtual-key call.workspace(). test_guardrail(..., ws_id=)andguardrail(gid, ws_id=): the guardrail tester on the workspace path, usable by workspace members.- The Playground (
playground(): test an unsaved detector body) andai_detector_body()for Natural Language detectors. - What a key can call:
available_models(),can_use_model(), plusmodels(),workspace_models(),grantable_providers(),attachable_policies()and workspacemodel_groups(). - Workspaces by name: every
ws_idargument accepts a workspace's id, its unique name, or its record;resolve_workspace()does the lookup (cached, refreshed on a miss; org admins and members alike). An unknown name raisesLookupError. - Detectors, guardrails, policies and virtual keys by name too, everywhere one
is taken (including the detector list of a guardrail, a policy's guardrails
and a key's policies). A detector name may be a built-in's name or slug; a
key may also be given by its
faig_...secret;approve/rejecttake a name withws_id. A custom detector that shares a built-in's name or slug is refused as ambiguous.resolve_detector/guardrail/policy/virtual_key(). - Re-runnable setup:
ensure_detector,ensure_guardrail,ensure_policyandensure_virtual_keycreate what is missing, update what differs, approve it, and leave a match alone; they returnEnsured(record, action, secret). - Typed errors, all subclasses of
FaigError:AuthError,NotFound,ModelNotAvailable,Conflict,ValidationError,RateLimited,ServerError; each carries the decodeddata, the gateway'scodeand thefielda 409/422 names.error_for()picks the class. rate_limit_wait=: wait out HTTP 429s for up to that many seconds (honouringRetry-Afterwhen sent) instead of raising.- Streaming:
chat_completion_stream()yields chunks, including a block'scontent_filterchunk;collect_stream()joins them. wait_for_traffic(): poll until a request's traffic log rows have landed.- Audit log:
iter_audit_logs()andaudit_logs_page()with time windows and user/role/action/message/workspace filters;licenses(). TypedDictrecord types (Workspace,Detector,Guardrail,Policy,VirtualKey,TrafficLogRow,TrafficLog,AuditLogRow) on the methods that return them, for editor completion. Records are still plain dicts.- A
pyfortiaigatecommand (python -m pyfortiaigate): workspaces, objects, keys, models, usage and cost, traffic and audit logs, guardrail scans and a test chat, as tables or--json. ReadsFAIG_*from the environment, or from a settings file only when--env-filenames one. pyfortiaigate.__version__.create_guardrail/update_guardrailtakedeny_suspicious_characters;update_virtual_keytakesprovider_ids.
Changed¶
workspace_analytics()takes an optional date window.approve_new()also approves an active object's pending change.- List and record methods are annotated with the record types above instead of
dict[str, Any]; strict type checkers may need adict(...)where a plain dict is expected.
[0.1.0] - 2026-10-12¶
Added¶
FortiAIGateclient for the 8.x management API and OpenAI-compatible gateway: users and invitations, workspaces, detectors, guardrails, policies, the approval workflow, virtual keys, providers, model groups, DLP labels, audit logs,check_keyandchat_completion.- Session login with re-login on expiry, a CSRF token per mutation, bearer
token auth, and
verifyas a CA bundle path. - Transport retries that never resend a mutation that may have landed, on one keep-alive connection.
- Body builders (
regex_detector_body,dlp_detector_body,guardrail_body) andapprove_new. delete_detector,delete_guardrail,delete_policy;key_update_body.- Live test suites: read-only, and opt-in writes confined to one workspace
(
FAIG_WRITE_WORKSPACE) that delete everything they create. Settings load from a gitignored.env(template:.env.example).
Verified live on FortiAIGate 8.0.2¶
me()readsGET /api/v1/user(/api/v1/medoes not exist).create_policytakesprovider_ids: a policy that grants no model is refused with HTTP 422.update_virtual_keysends only the fields a key PUT accepts, withpolicyIdsas plain strings; resending the whole record is refused.revoke_virtual_keysends thereasonthe API requires.